diff --git a/.dockerignore b/.dockerignore
index ac38bfb2..06fae233 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -34,7 +34,7 @@ README.md
CONTRIBUTING.md
LICENSE
-repo-images/
+docs/repo-images/
uploads/
diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml
new file mode 100644
index 00000000..4f189677
--- /dev/null
+++ b/.github/workflows/beta-release.yml
@@ -0,0 +1,179 @@
+name: Weekly Beta Release
+
+on:
+ schedule:
+ - cron: "15 6 * * 1"
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Build and test but do not push images, upload installers, or publish a release"
+ required: false
+ default: false
+ type: boolean
+
+permissions:
+ contents: write
+
+jobs:
+ prep:
+ runs-on: blacksmith-2vcpu-ubuntu-2404
+ outputs:
+ dev_branch: ${{ steps.dev.outputs.branch }}
+ beta_version: ${{ steps.dev.outputs.beta_version }}
+ sha: ${{ steps.dev.outputs.sha }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 1
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: ".nvmrc"
+
+ - name: Resolve newest dev branch and compute beta version
+ id: dev
+ env:
+ GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
+ run: |
+ REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
+ if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
+ echo "Newest dev branch: $DEV_BRANCH"
+ else
+ echo "No dev-X.Y.Z branch open; nothing to snapshot for this week's beta."
+ echo "branch=" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ BASE_VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
+ BETA_VERSION="${BASE_VERSION}-beta.$(date -u +%Y%m%d)"
+ SHA=$(gh api "repos/${{ github.repository }}/branches/$DEV_BRANCH" -q .commit.sha)
+
+ echo "Beta version: $BETA_VERSION"
+ echo "branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
+ echo "beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"
+ echo "sha=$SHA" >> "$GITHUB_OUTPUT"
+
+ verify:
+ needs: [prep]
+ if: ${{ needs.prep.outputs.dev_branch != '' }}
+ runs-on: blacksmith-2vcpu-ubuntu-2404
+ steps:
+ - name: Checkout dev branch
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.prep.outputs.dev_branch }}
+ fetch-depth: 1
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: ".nvmrc"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run ESLint
+ run: npx eslint .
+
+ - name: Run Prettier check
+ run: npx prettier --check .
+
+ - name: Type check
+ run: npx tsc --noEmit
+
+ - name: Run unit tests
+ run: npm run test
+
+ - name: Build
+ run: npm run build
+
+ create-release:
+ needs: [prep, verify]
+ if: ${{ needs.prep.outputs.dev_branch != '' && inputs.dry_run != true }}
+ runs-on: blacksmith-2vcpu-ubuntu-2404
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout dev branch
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.prep.outputs.dev_branch }}
+ fetch-depth: 0
+
+ - name: Resolve previous beta commit
+ id: prev
+ env:
+ GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
+ run: |
+ PREV_SHA=$(gh release view beta --repo ${{ github.repository }} --json targetCommitish -q .targetCommitish 2>/dev/null || true)
+ if [ -n "$PREV_SHA" ] && git cat-file -e "$PREV_SHA" 2>/dev/null && git merge-base --is-ancestor "$PREV_SHA" "${{ needs.prep.outputs.sha }}"; then
+ echo "sha=$PREV_SHA" >> "$GITHUB_OUTPUT"
+ else
+ echo "sha=" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Generate rolling beta release notes
+ run: |
+ if [ -n "${{ steps.prev.outputs.sha }}" ]; then
+ CHANGES=$(git log --oneline --no-merges "${{ steps.prev.outputs.sha }}..${{ needs.prep.outputs.sha }}" -- . ':!package-lock.json' | sed 's/^/- /')
+ fi
+ if [ -z "$CHANGES" ]; then
+ CHANGES="- No new commits since the last beta (or this is the first beta build)."
+ fi
+
+ cat > BETA_RELEASE_BODY.md << EOF
+ > [!WARNING]
+ > This is an automated weekly beta build, snapshotted from the \`${{ needs.prep.outputs.dev_branch }}\` branch. It is not a stable release: it may contain unfinished features, regressions, or breaking changes, and this tag is overwritten every week. Do not run it in production.
+ >
+ > Found a bug? [Open a Beta Feedback report](https://github.com/Termix-SSH/Support/issues/new?template=beta_feedback.yml) and mention this build: \`${{ needs.prep.outputs.beta_version }}\`.
+
+ **Snapshot of:** \`${{ needs.prep.outputs.dev_branch }}\` @ \`${{ needs.prep.outputs.sha }}\`
+ **Docker image:** \`ghcr.io/lukegus/termix:beta\` / \`docker.io/bugattiguy527/termix:beta\` (rolling), or pin to \`:beta-${{ needs.prep.outputs.beta_version }}\` for this exact build.
+ **Built:** $(date -u +"%Y-%m-%d %H:%M UTC")
+
+ ### Changes since last beta
+
+ $CHANGES
+ EOF
+
+ - name: Create or update rolling beta release
+ env:
+ GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
+ run: |
+ TAG="beta"
+ TITLE="Beta (rolling) - ${{ needs.prep.outputs.beta_version }}"
+ if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
+ gh release edit "$TAG" --repo ${{ github.repository }} \
+ --title "$TITLE" --notes-file BETA_RELEASE_BODY.md \
+ --prerelease --target "${{ needs.prep.outputs.sha }}"
+ else
+ gh release create "$TAG" --repo ${{ github.repository }} \
+ --title "$TITLE" --notes-file BETA_RELEASE_BODY.md \
+ --prerelease --target "${{ needs.prep.outputs.sha }}"
+ fi
+
+ docker:
+ needs: [prep, verify, create-release]
+ if: ${{ always() && needs.prep.outputs.dev_branch != '' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
+ uses: ./.github/workflows/docker.yml
+ with:
+ version: ${{ needs.prep.outputs.beta_version }}
+ build_type: Beta
+ dry_run: ${{ inputs.dry_run == true }}
+ source_ref: ${{ needs.prep.outputs.sha }}
+ secrets: inherit
+
+ electron-release:
+ needs: [prep, verify, create-release]
+ if: ${{ always() && needs.prep.outputs.dev_branch != '' && inputs.dry_run != true && needs.create-release.result == 'success' }}
+ uses: ./.github/workflows/electron.yml
+ with:
+ build_type: all
+ artifact_destination: release
+ release_tag: beta
+ version_override: ${{ needs.prep.outputs.beta_version }}
+ source_ref: ${{ needs.prep.outputs.sha }}
+ secrets: inherit
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 38098726..212f62cc 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -13,7 +13,12 @@ on:
type: choice
options:
- Development
+ - Beta
- Production
+ source_ref:
+ description: "Git ref/SHA to build (defaults to the workflow ref)"
+ required: false
+ default: ""
workflow_call:
inputs:
version:
@@ -29,6 +34,11 @@ on:
required: false
type: boolean
default: false
+ source_ref:
+ description: "Git ref/SHA to build"
+ required: false
+ type: string
+ default: ""
jobs:
build:
@@ -37,8 +47,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
+ - name: Resolve source revision
+ run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
@@ -62,6 +76,12 @@ jobs:
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
done
+ elif [ "$BUILD_TYPE" = "Beta" ]; then
+ TAGS+=("beta" "beta-$VERSION")
+ for tag in "${TAGS[@]}"; do
+ ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
+ ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
+ done
else
TAGS+=("dev-$VERSION")
for tag in "${TAGS[@]}"; do
@@ -79,8 +99,8 @@ jobs:
username: lukegus
password: ${{ secrets.GHCR_TOKEN }}
- - name: Login to Docker Hub (prod only)
- if: ${{ inputs.build_type == 'Production' && !inputs.dry_run }}
+ - name: Login to Docker Hub (prod and beta only)
+ if: ${{ (inputs.build_type == 'Production' || inputs.build_type == 'Beta') && !inputs.dry_run }}
uses: docker/login-action@v4
with:
username: bugattiguy527
@@ -98,7 +118,7 @@ jobs:
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
org.opencontainers.image.created=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml
index c6ac48ed..21980fd8 100644
--- a/.github/workflows/electron.yml
+++ b/.github/workflows/electron.yml
@@ -23,6 +23,10 @@ on:
- file
- release
- submit
+ source_ref:
+ description: "Git ref/SHA to build (defaults to the workflow ref)"
+ required: false
+ default: ""
workflow_call:
inputs:
build_type:
@@ -38,6 +42,16 @@ on:
required: false
type: string
default: ""
+ version_override:
+ description: "Version string to stamp into built artifacts instead of package.json's version"
+ required: false
+ type: string
+ default: ""
+ source_ref:
+ description: "Git ref/SHA to build"
+ required: false
+ type: string
+ default: ""
outputs:
macos_universal_dmg_sha256:
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
@@ -54,6 +68,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -68,7 +83,10 @@ jobs:
- name: Get version
id: package-version
run: |
- $VERSION = (Get-Content package.json | ConvertFrom-Json).version
+ $VERSION = "${{ inputs.version_override }}"
+ if ([string]::IsNullOrEmpty($VERSION)) {
+ $VERSION = (Get-Content package.json | ConvertFrom-Json).version
+ }
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
- name: Build Windows (All Architectures)
@@ -144,6 +162,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -274,7 +293,10 @@ jobs:
- name: Get version for Flatpak
id: flatpak-version
run: |
- VERSION=$(node -p "require('./package.json').version")
+ VERSION="${{ inputs.version_override }}"
+ if [ -z "$VERSION" ]; then
+ VERSION=$(node -p "require('./package.json').version")
+ fi
RELEASE_DATE=$(date +%Y-%m-%d)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
@@ -288,9 +310,9 @@ jobs:
CHECKSUM_ARM64=$(sha256sum "release/termix_linux_arm64_appimage.AppImage" | awk '{print $1}')
mkdir -p flatpak-build
- cp flatpak/com.karmaa.termix.yml flatpak-build/
- cp flatpak/com.karmaa.termix.desktop flatpak-build/
- cp flatpak/com.karmaa.termix.metainfo.xml flatpak-build/
+ cp packaging/flatpak/com.karmaa.termix.yml flatpak-build/
+ cp packaging/flatpak/com.karmaa.termix.desktop flatpak-build/
+ cp packaging/flatpak/com.karmaa.termix.metainfo.xml flatpak-build/
cp public/icon.svg flatpak-build/com.karmaa.termix.svg
convert public/icon.png -resize 256x256 flatpak-build/icon-256.png
convert public/icon.png -resize 128x128 flatpak-build/icon-128.png
@@ -322,7 +344,7 @@ jobs:
- name: Create flatpakref file
run: |
VERSION="${{ steps.flatpak-version.outputs.version }}"
- cp flatpak/com.karmaa.termix.flatpakref release/
+ cp packaging/flatpak/com.karmaa.termix.flatpakref release/
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
- name: Upload Flatpak bundle
@@ -354,6 +376,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -514,7 +537,10 @@ jobs:
- name: Get version for Homebrew
id: homebrew-version
run: |
- VERSION=$(node -p "require('./package.json').version")
+ VERSION="${{ inputs.version_override }}"
+ if [ -z "$VERSION" ]; then
+ VERSION=$(node -p "require('./package.json').version")
+ fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Compute universal DMG checksum
@@ -525,7 +551,7 @@ jobs:
echo "sha256=$CHECKSUM" >> $GITHUB_OUTPUT
- name: Generate Homebrew Cask
- if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release')
+ if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && inputs.version_override == '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release')
run: |
VERSION="${{ steps.homebrew-version.outputs.version }}"
DMG_PATH="release/termix_macos_universal_dmg.dmg"
@@ -533,7 +559,7 @@ jobs:
CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')
mkdir -p homebrew-generated
- cp Casks/termix.rb homebrew-generated/termix.rb
+ cp packaging/Casks/termix.rb homebrew-generated/termix.rb
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
@@ -550,7 +576,7 @@ jobs:
retention-days: 30
- name: Upload Homebrew Cask to release
- if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'release'
+ if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.version_override == '' && inputs.artifact_destination == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -580,6 +606,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -619,7 +646,7 @@ jobs:
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME"
New-Item -ItemType Directory -Force -Path "choco-build"
- Copy-Item -Path "chocolatey\*" -Destination "choco-build" -Recurse -Force
+ Copy-Item -Path "packaging\chocolatey\*" -Destination "choco-build" -Recurse -Force
$installScript = Get-Content "choco-build\tools\chocolateyinstall.ps1" -Raw -Encoding UTF8
$installScript = $installScript -replace 'DOWNLOAD_URL_PLACEHOLDER', $DOWNLOAD_URL
@@ -686,6 +713,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -737,10 +765,10 @@ jobs:
mkdir -p flatpak-submission
- cp flatpak/com.karmaa.termix.yml flatpak-submission/
- cp flatpak/com.karmaa.termix.desktop flatpak-submission/
- cp flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
- cp flatpak/flathub.json flatpak-submission/
+ cp packaging/flatpak/com.karmaa.termix.yml flatpak-submission/
+ cp packaging/flatpak/com.karmaa.termix.desktop flatpak-submission/
+ cp packaging/flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
+ cp packaging/flatpak/flathub.json flatpak-submission/
cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
@@ -823,6 +851,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -865,7 +894,7 @@ jobs:
mkdir -p homebrew-submission/Casks/t
- cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
+ cp packaging/Casks/termix.rb 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
@@ -933,6 +962,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -1029,18 +1059,26 @@ jobs:
VERSION=$(node -p "require('./package.json').version")
- # Write API key JSON that Fastlane deliver expects
+ # Write API key JSON that Fastlane deliver expects; the PEM's
+ # newlines must be preserved as literal \n escapes, not stripped,
+ # or spaceship fails to parse the key (invalid curve name).
mkdir -p /tmp/asc_keys
KEY_P8_PATH="/tmp/asc_keys/AuthKey_${APPLE_KEY_ID}.p8"
API_KEY_JSON="/tmp/asc_keys/api_key.json"
echo "$APPLE_KEY_CONTENT" | base64 --decode > "$KEY_P8_PATH"
- printf '{\n "key_id": "%s",\n "issuer_id": "%s",\n "key": "%s",\n "in_house": false\n}\n' \
- "$APPLE_KEY_ID" \
- "$APPLE_ISSUER_ID" \
- "$(tr -d '\n' < "$KEY_P8_PATH")" \
- > "$API_KEY_JSON"
+ KEY_ID="$APPLE_KEY_ID" ISSUER_ID="$APPLE_ISSUER_ID" KEY_P8_PATH="$KEY_P8_PATH" \
+ node -e '
+ const fs = require("fs");
+ const key = fs.readFileSync(process.env.KEY_P8_PATH, "utf8");
+ process.stdout.write(JSON.stringify({
+ key_id: process.env.KEY_ID,
+ issuer_id: process.env.ISSUER_ID,
+ key,
+ in_house: false,
+ }, null, 2) + "\n");
+ ' > "$API_KEY_JSON"
fastlane deliver \
--pkg "$PKG_FILE" \
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e160d085..8e4f22e5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -278,7 +278,7 @@ jobs:
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
- echo "build_ref=main" >> "$GITHUB_OUTPUT"
+ echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
docker:
needs: [prep, merge-to-main]
@@ -287,6 +287,7 @@ jobs:
version: ${{ needs.prep.outputs.version }}
build_type: Production
dry_run: ${{ inputs.mode == 'Dry run' }}
+ source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
create-release:
@@ -351,15 +352,17 @@ jobs:
build_type: all
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
release_tag: ${{ needs.prep.outputs.release_tag }}
+ source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
electron-submit:
- needs: [prep, electron-release]
+ needs: [prep, merge-to-main, electron-release]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: submit
+ source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
cask-commit-back:
@@ -386,20 +389,21 @@ jobs:
exit 0
fi
- sed -i "s|version \".*\"|version \"$VERSION\"|g" Casks/termix.rb
- sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" Casks/termix.rb
+ git config user.name "LukeGus"
+ git config user.email "bugattiguy527@gmail.com"
- if git diff --quiet Casks/termix.rb; then
+ git fetch origin main
+ git checkout -B main origin/main
+
+ sed -i "s|version \".*\"|version \"$VERSION\"|g" packaging/Casks/termix.rb
+ sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" packaging/Casks/termix.rb
+
+ git add packaging/Casks/termix.rb
+ if git diff --cached --quiet; then
echo "Cask already up to date."
exit 0
fi
- git config user.name "LukeGus"
- git config user.email "bugattiguy527@gmail.com"
- git add Casks/termix.rb
- git stash
- git pull --rebase origin main
- git stash pop
git commit -m "chore: bump Homebrew cask to $VERSION"
git push origin HEAD:main
@@ -530,7 +534,7 @@ jobs:
docs,
publish-youtube,
]
- if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' && needs.cask-commit-back.result == 'success' && needs.docs.result == 'success' && needs.publish-youtube.result == 'success' }}
+ if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Delete dev branch in Termix
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
deleted file mode 100644
index 6975ffb1..00000000
--- a/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our
-community include:
-
-- Demonstrating empathy and kindness toward other people
-- Being respectful of differing opinions, viewpoints, and experiences
-- Giving and gracefully accepting constructive feedback
-- Accepting responsibility and apologizing to those affected by our mistakes,
- and learning from the experience
-- Focusing on what is best not just for us as individuals, but for the
- overall community
-
-Examples of unacceptable behavior include:
-
-- The use of sexualized language or imagery, and sexual attention or
- advances of any kind
-- Trolling, insulting or derogatory comments, and personal or political attacks
-- Public or private harassment
-- Publishing others' private information, such as a physical or email
- address, without their explicit permission
-- Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Enforcement Responsibilities
-
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for moderation
-decisions when appropriate.
-
-## Scope
-
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official e-mail address,
-posting via an official social media account, or acting as an appointed
-representative at an online or offline event.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the community leaders responsible for enforcement at
-mail@termix.site.
-All complaints will be reviewed and investigated promptly and fairly.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
-
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
-
-### 2. Warning
-
-**Community Impact**: A violation through a single incident or series
-of actions.
-
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interaction with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or
-permanent ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interaction
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage],
-version 2.0, available at
-https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
-
-Community Impact Guidelines were inspired by [Mozilla's code of conduct
-enforcement ladder](https://github.com/mozilla/diversity).
-
-[homepage]: https://www.contributor-covenant.org
-
-For answers to common questions about this code of conduct, see the FAQ at
-https://www.contributor-covenant.org/faq. Translations are available at
-https://www.contributor-covenant.org/translations.
diff --git a/README.md b/README.md
index 4e75ff40..6ec99960 100644
--- a/README.md
+++ b/README.md
@@ -8,19 +8,19 @@
English ·
- 中文 ·
- 日本語 ·
- 한국어 ·
- Français ·
- Deutsch ·
- Español ·
- Português ·
- Русский ·
- العربية ·
- हिन्दी ·
- Türkçe ·
- Tiếng Việt ·
- Italiano
+ 中文 ·
+ 日本語 ·
+ 한국어 ·
+ Français ·
+ Deutsch ·
+ Español ·
+ Português ·
+ Русский ·
+ العربية ·
+ हिन्दी ·
+ Türkçe ·
+ Tiếng Việt ·
+ Italiano
@@ -41,13 +41,13 @@ Termix is free and open source. If you find it useful, consider [donating](https
-
+
-
+
Achieved on September 1st, 2025
@@ -117,7 +117,7 @@ View CPU, memory, disk usage, network, uptime, system information, firewall, por
**User Authentication:**
-Secure user management with admin controls and OIDC/LDAP/SSO (with access control), 2FA (TOTP), and passkey (WebAuthn) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.
+Secure user management with admin controls (can edit other users information) and OIDC/LDAP/SSO (with access control), 2FA (TOTP), and passkey (WebAuthn) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.
|
@@ -130,8 +130,8 @@ List devices from your tailnet to quickly add them as hosts, and connect using T
-**RBAC:**
-Create roles and share hosts across users/roles.
+**RBAC/Sharing:**
+Create roles and share hosts across users/roles. Supports all auth types and all host protocols.
|
@@ -295,74 +295,16 @@ networks:
## Donate
-Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time.
+Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time. Donations also help fund the time to research and learn what's needed to build features like SAML, Kubernetes, and Agent support. Track progress and donate below.
[Donate](https://donate.termix.site/)
-## Screenshots
-
-
-
-
-
-## Planned Features
-
-See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
-
-
-
## Sponsors
+Interested in a paid placement to support development? Email [mail@termix.site](mailto:mail@termix.site).
+
@@ -396,7 +338,7 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned fe
-
+
@@ -409,6 +351,66 @@ If you need help or want to request a feature with Termix, visit the [Issues](ht
+## Screenshots
+
+
+
+
+
+## Planned Features
+
+See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+
+
## License
Distributed under the Apache License Version 2.0. See `LICENSE` for more information.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 2718dc5c..e4ab722e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,6 @@
-Major new features including serial connections, Tailscale/WireGuard support, HashiCorp Vault SSH auth, Bitwarden SSH agent, WebAuthn passkeys, Podman support, a new grid-based dashboard, host metrics history with alerting, and much more.
+Revamped RBAC/sharing, session recording & replay, Vault auth for monitors, API key host enrollment, Proxmox guest auto sync, database refactor, plus 30+ bug fixes across terminal, file manager, RDP/VNC, and auth. DO NOT DOWNGRADE FROM THIS VERSION.
@@ -12,70 +12,59 @@ https://youtu.be/c3UD4q2jW_8
-- Termix ID with a public handle, hosted public key resolver, and built-in CA for issuing SSH certificates
-- Serial connections support
-- Tailscale and WireGuard VPN host integration with status detection
-- HashiCorp Vault SSH signer authentication
-- Bitwarden SSH agent integration
-- WebAuthn passkey authentication
-- Podman container runtime support alongside Docker
-- SSH agent forwarding support across all SSH features
-- New grid and widget-based dashboard homepage
-- Grafana-style server stats history graphs
-- Alert system with ntfy and webhook notification support
-- Host temperature metrics card
-- App fullscreen mode
-- External editor support for file manager (desktop app)
-- Safe host sharing export
-- SSH credential password fallback for key-based auth
-- Open all sessions in a folder at once
-- Custom terminal theme color support
-- Custom tunnel endpoints configuration
-- GUACD_URL environment variable support
-- App rail hover expansion setting
-- Terminal font zoom with mouse wheel
-- File manager terminals promoted to full tabs
-- Donate button on dashboard
-- PuTTY PPK SSH key support
-- Confirmation dialog when closing active host connections
-- Confirmation prompt before opening large files in the editor
-- Cross-host file manager clipboard
-- Prioritize host results in command palette search
-- Retry autostart tunnel host fetches on failure
+- Revamped RBAC/sharing system (new UI, all auth types and host protocols now supported)
+- Complete admin control over user information (manage all users hosts, credentials, and snippets)
+- Support Vault auth for monitors
+- API key host enrollment endpoint
+- Allow pinned hosts with name sorting
+- Session recording and replay
+- Terminal font size shortcuts (ctrl + / -)
+- Open File Manager to tab right-click menu
+- Proxmox guest auto sync
+- Complete database refactor
+- 30-day donation reminder and new donation milestones that support research: (donate.termix.site)
+- Improve site performance with cache and poll pauses
+- Save quick connect sessions as hosts
-- SSH port connection bug
-- VNC required argument handshake failure
-- Jump host SOCKS5 proxy selection using wrong proxy
-- Tunnel endpoint resolution failing in some configurations
-- Direct tunnel skipping endpoint credential validation incorrectly
-- Dashboard host routing ignoring protocol settings
-- Dashboard service link creation broken
-- File manager uploads failing with 400 error and missing schema migrations on upgrade
-- Large file manager uploads not chunked (chunked for files >=1.5GB)
-- File uploads over 100MB failing due to ArrayBuffer browser limit
-- File path case not preserved in file manager UI
-- File downloads unreliable in the desktop app
-- Tmux detection path handling incorrect
-- Host metrics startup polling incorrect
-- TUI terminal output highlighting incorrect
-- Runtime base path for auth callbacks incorrect
-- Windows app icon unstable
-- SSH heading syntax highlighting broken
-- Terminal link dialog layering issue
-- Electron OIDC browser authentication failures
-- Proxmox import auth fallback not working
-- OIDC role credential shares not synced for OIDC users
-- RDP connections requiring credentials when none are needed
-- VNC authentication settings not persisted
-- Guacamole unicode token corruption
-- Guacamole websocket base path incorrect
-- Guacamole disconnect during startup crash
-- Host metrics starting for non-SSH hosts
-- Sidebar host hover causing layout shift
-- Alert UI incorrectly applying Termix CSS and alert system failing to load
-- Translation key incorrect for nav close action
-- PUID HTML ownership in Docker entrypoint
+- Syntax highlighting artifacts
+- Filter dashboard status hosts
+- Persist dashboard service link changes
+- Snippet text overflow
+- Persist remote desktop credential auth
+- Guard language switching failures
+- Resolve tunnel source credentials
+- Windows file delete command
+- Artifact release checkout ref
+- Command palette escape in fullscreen
+- Alerts and audit log normalization
+- macOS VNC protocol negotiation
+- Port knocking before SSH connect
+- Allow escape to close link confirmation
+- Prevent Electron modifier wheel zoom
+- Credential auth optional password
+- Retry transient terminal DNS lookups
+- OIDC redirect forwarded port handling
+- Preserve recent open tabs on startup
+- Terminal font selection
+- Poor font legibility in multiple places
+- File manager uploads failing
+- Tmux detection for non-POSTIX shells
+- OPKSSH js-yaml ESM import
+- Android Vietnamese IME input
+- Firefox RDP clipboard paste
+- Proxmox discovery over HTTPS
+- External editor actions in file preview
+- Firefox desktop OIDC callback
+- Status checks through jump hosts
+- Restore sudo password auto fill settings
+- Preserve file editor position on save
+- Sync cloud preference storage mode
+- Render RDP sessions at native pixel density
+- Restore database import in embedded desktop mode
+- Command autocomplete dropdown poor contrast
+- Allow clipboard paste in key recording field
+- Fix GitHub/google SSO "not defined" errors
diff --git a/docker/Dockerfile b/docker/Dockerfile
index a016afbd..f6ba5f51 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Install dependencies
-FROM node:24-slim AS deps
+FROM node:26-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
RUN npm run build:backend
# Stage 4: Production dependencies only
-FROM node:24-slim AS production-deps
+FROM node:26-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 5: Final optimized image
-FROM node:24-slim
+FROM node:26-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml
index 20a632b5..14b703a8 100644
--- a/docker/compose-dev.yml
+++ b/docker/compose-dev.yml
@@ -12,6 +12,8 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
+ GUACD_HOST: "guacd-dev"
+ GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd-dev
networks:
@@ -21,6 +23,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
+ volumes:
+ - termix-dev-data:/termix-data
networks:
- termix-dev-net
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 9a2db205..eed4d172 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -10,6 +10,7 @@ services:
environment:
PORT: "8080"
GUACD_HOST: "guacd"
+ GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd
networks:
@@ -19,6 +20,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
+ volumes:
+ - termix-data:/termix-data
networks:
- termix-net
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index d2e38f5b..9449028a 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -235,6 +235,18 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location ~ ^/proxmox(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 120s;
+ proxy_read_timeout 120s;
+ }
+
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -433,8 +445,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
- proxy_set_header X-Forwarded-Port $server_port;
- proxy_set_header X-Forwarded-Host $http_host;
+ proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
+ proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -676,8 +688,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
- proxy_set_header X-Forwarded-Port $server_port;
- proxy_set_header X-Forwarded-Host $http_host;
+ proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
+ proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index a8a44e2a..68cff5a6 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -434,8 +434,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
- proxy_set_header X-Forwarded-Port $server_port;
- proxy_set_header X-Forwarded-Host $http_host;
+ proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
+ proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -677,8 +677,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
- proxy_set_header X-Forwarded-Port $server_port;
- proxy_set_header X-Forwarded-Host $http_host;
+ proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
+ proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
diff --git a/readme/README-AR.md b/docs/readme/README-AR.md
similarity index 84%
rename from readme/README-AR.md
rename to docs/readme/README-AR.md
index 1a3066a4..9d208394 100644
--- a/readme/README-AR.md
+++ b/docs/readme/README-AR.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
-
-
@@ -81,13 +83,13 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
**إدارة أنفاق SSH:**
-إنشاء وإدارة أنفاق SSH بين الخوادم مع إعادة الاتصال التلقائي ومراقبة الحالة وإعادة التوجيه المحلي أو البعيد أو SOCKS الديناميكي. يتم تخزين إعدادات نفق العميل-المكتبي إلى السيرفر محلياً لكل تثبيت مكتبي؛ يمكن حفظ لقطات C2S الاختيارية على الخادم وإعادة تسميتها وتحميلها أو حذفها لنقل تكوين النفق المحلي بين العملاء.
+إنشاء وإدارة أنفاق SSH بين الخوادم مع إعادة الاتصال التلقائي ومراقبة الحالة وإعادة التوجيه المحلي أو البعيد أو SOCKS الديناميكي. يتم تخزين إعدادات نفق العميل-المكتبي إلى السيرفر محلياً لكل تثبيت مكتبي، ويمكن حفظ لقطات C2S الاختيارية على الخادم وإعادة تسميتها وتحميلها أو حذفها عندما تريد نقل تكوين النفق المحلي بين العملاء.
|
**مدير الملفات عن بُعد:**
-إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo.
+إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo. يتضمن دعم نقل الملفات من خادم إلى آخر.
|
@@ -109,13 +111,13 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
**مقاييس المضيف:**
-عرض استخدام المعالج والذاكرة والقرص والشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ وعارض السجلات والمستخدمين/الصلاحيات والشهادات وغيرها الكثير، تعمل على معظم الخوادم المبنية على Linux.
+عرض استخدام المعالج والذاكرة والقرص والشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ وعارض السجلات والمستخدمين/الصلاحيات والشهادات وغيرها الكثير، تعمل على معظم الخوادم المبنية على Linux. يتضمن رسوم بيانية تاريخية زمنية السلسلة وتنبيهات قائمة على الحدود مع دعم ntfy والـ webhook.
|
**مصادقة المستخدمين:**
-إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC/LDAP/SSO (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. عرض سجل تدقيق لجميع إجراءات المستخدمين.
+إدارة آمنة للمستخدمين مع ضوابط إدارية (يمكن تعديل معلومات المستخدمين الآخرين) ودعم OIDC/LDAP/SSO (مع التحكم في الوصول) و 2FA (TOTP) ودعم مفاتيح المرور (WebAuthn). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. عرض سجل تدقيق لجميع إجراءات المستخدمين.
|
@@ -128,8 +130,8 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
-**RBAC:**
-إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
+**RBAC/المشاركة:**
+إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار. يدعم جميع أنواع المصادقة وجميع بروتوكولات المضيف.
|
@@ -206,7 +208,8 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
- **تكامل Proxmox** - إضافة المضيفات تلقائياً إلى Termix من نسخة Proxmox الخاصة بك
-- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، تسجيل الطرفية، إلخ.
+- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، تسجيل الطرفية، إعادة توجيه وكيل SSH، وكيل Bitwarden SSH، توقيع SSH عبر HashiCorp Vault، وغيرها.
+- **Termix ID** - مكافئ لـ sshid.io مدمج في Termix. احصل على اسم مستخدم، انشر مفاتيح SSH العامة الخاصة بك على رابط محلل (resolver URL)، واستخدم هيئة إصدار شهادات (CA) مدمجة لإصدار شهادات SSH.
@@ -249,7 +252,9 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
## التثبيت
-قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. يمكنك الاطلاع على نموذج ملف Docker Compose هنا (يمكنك حذف guacd والشبكة إذا كنت لا تخطط لاستخدام ميزات سطح المكتب البعيد):
+قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على تعليمات التثبيت الكاملة عبر جميع المنصات.
+
+نموذج ملف Docker Compose (يمكنك حذف `guacd` والشبكة إذا كنت لا تخطط لاستخدام ميزات سطح المكتب البعيد):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## التبرع
-Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
+Termix مجاني ومفتوح المصدر بدون اشتراكات أو خطط مدفوعة. إذا وجدته مفيدًا، فكّر في التبرع للمساعدة في تغطية تكاليف الخادم والنطاقات ووقت التطوير. تساعد التبرعات أيضاً في تمويل الوقت اللازم للبحث وتعلم ما هو مطلوب لبناء ميزات مثل SAML و Kubernetes ودعم الوكلاء (Agent). تابع التقدم وتبرع أدناه.
-
+[تبرع](https://donate.termix.site/)
+
+
+
+## الرعاة
+
+هل تريد إعلاناً مدفوعاً لدعم التطوير؟ راسلنا عبر البريد الإلكتروني [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## الدعم
+
+إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول.
@@ -356,50 +411,6 @@ Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ف
-## الرعاة
-
-
-
-
-
-## الدعم
-
-إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول.
-
-
-
## الترخيص
موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف `LICENSE` لمزيد من المعلومات.
diff --git a/readme/README-CN.md b/docs/readme/README-CN.md
similarity index 85%
rename from readme/README-CN.md
rename to docs/readme/README-CN.md
index c4fa5331..2290b99b 100644
--- a/readme/README-CN.md
+++ b/docs/readme/README-CN.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
-
-
@@ -56,7 +58,7 @@ Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://do
## 概览
-Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能、远程 SSH 文件管理以及许多其他工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
+Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能、远程文件管理以及许多其他工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
@@ -87,7 +89,7 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
**远程文件管理器:**
-直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。
+直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。包括支持在服务器之间移动文件。
|
@@ -109,13 +111,13 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
**主机指标:**
-在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况、网络、运行时间、系统信息、防火墙、端口监控、日志查看器、用户/权限、证书等更多信息。
+在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况、网络、运行时间、系统信息、防火墙、端口监控、日志查看器、用户/权限、证书等更多信息。包括时间序列历史图表和支持 ntfy 与 webhook 的阈值告警。
|
**用户认证:**
-安全的用户管理,具有管理员控制、OIDC/LDAP/SSO(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。查看所有用户操作的审计日志。
+安全的用户管理,具有管理员控制(可编辑其他用户信息)和 OIDC/LDAP/SSO(带访问控制)、2FA (TOTP) 以及通行密钥(WebAuthn)支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。查看所有用户操作的审计日志。
|
@@ -128,8 +130,8 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
-**RBAC:**
-创建角色并在用户/角色之间共享主机。
+**RBAC/共享:**
+创建角色并在用户/角色之间共享主机。支持所有认证类型和所有主机协议。
|
@@ -206,7 +208,8 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- **快速连接** - 无需保存连接数据即可连接到服务器
- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接
- **Proxmox 集成** - 从您的 Proxmox 实例自动将主机添加到 Termix
-- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击、终端日志记录等
+- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击、终端日志记录、SSH 代理转发、Bitwarden SSH 代理、HashiCorp Vault SSH 签名等
+- **Termix ID** - 内置于 Termix 中的 sshid.io 等效功能。认领一个用户名,在解析 URL 上发布您的公开 SSH 密钥,并使用内置 CA 签发 SSH 证书。
@@ -249,7 +252,9 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
## 安装
-访问 [Termix 文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。此外,这里有一个示例 Docker Compose 文件(如果您不打算使用远程桌面功能,可以省略 guacd 和网络部分):
+访问 [Termix 文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的完整说明。
+
+示例 Docker Compose 文件(如果您不打算使用远程桌面功能,可以省略 `guacd` 和网络部分):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## 捐赠
-Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
+Termix 免费且开源,没有订阅或付费方案。如果您觉得它有用,请考虑捐赠以帮助支付服务器费用、域名和开发时间。捐赠还有助于资助研究和学习构建 SAML、Kubernetes 和 Agent 支持等功能所需的时间。在下方追踪进度并进行捐赠。
-
+[捐赠](https://donate.termix.site/)
+
+
+
+## 赞助商
+
+有意通过付费展示位置支持开发吗?请发送邮件至 [mail@termix.site](mailto:mail@termix.site)。
+
+
+
+
+
+## 支持
+
+如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。
@@ -356,50 +411,6 @@ Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://do
-## 赞助商
-
-
-
-
-
-## 支持
-
-如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。
-
-
-
## 许可证
根据 Apache License Version 2.0 发布。更多信息请参见 `LICENSE`。
diff --git a/readme/README-DE.md b/docs/readme/README-DE.md
similarity index 80%
rename from readme/README-DE.md
rename to docs/readme/README-DE.md
index deb0836f..6ca2f9af 100644
--- a/readme/README-DE.md
+++ b/docs/readme/README-DE.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken.
-
-
@@ -56,7 +58,7 @@ Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie
## Uberblick
-Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformubergreifende Losung zur Verwaltung Ihrer Server und Infrastruktur uber eine einzige, intuitive Oberflache. Termix bietet SSH-Terminalzugriff, Remote-Desktop-Steuerung (RDP, VNC, Telnet), SSH-Tunneling-Funktionen, Remote-SSH-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfugbar fur alle Plattformen.
+Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformubergreifende Losung zur Verwaltung Ihrer Server und Infrastruktur uber eine einzige, intuitive Oberflache. Termix bietet SSH-Terminalzugriff, Remote-Desktop-Steuerung (RDP, VNC, Telnet), SSH-Tunneling-Funktionen, Remote-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfugbar fur alle Plattformen.
@@ -81,13 +83,13 @@ RDP-, VNC- und Telnet-Unterstutzung uber den Browser mit vollstandiger Anpassung
**SSH-Tunnelverwaltung:**
-Erstellen und verwalten Sie Server-zu-Server-SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsuberwachung sowie lokaler, entfernter oder dynamischer SOCKS-Weiterleitung. Desktop-Client-zu-Server-Tunneleinstellungen werden lokal pro Desktop-Installation gespeichert, optionale C2S-Preset-Snapshots konnen auf dem Server gespeichert, umbenannt, geladen oder geloscht werden, um eine lokale Tunnelkonfiguration zwischen Clients zu ubertragen.
+Erstellen und verwalten Sie Server-zu-Server-SSH-Tunnel mit automatischer Wiederverbindung, Gesundheitsuberwachung sowie lokaler, entfernter oder dynamischer SOCKS-Weiterleitung. Desktop-Client-zu-Server-Tunneleinstellungen werden lokal pro Desktop-Installation gespeichert, optionale C2S-Preset-Snapshots konnen auf dem Server gespeichert, umbenannt, geladen oder geloscht werden, wenn Sie eine lokale Tunnelkonfiguration zwischen Clients ubertragen mochten.
|
**Remote-Dateimanager:**
-Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, loschen oder verschieben Sie sie nahtlos mit Sudo-Unterstutzung.
+Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, loschen oder verschieben Sie sie nahtlos mit Sudo-Unterstutzung. Enthalt Unterstutzung fur das Verschieben von Dateien von Server zu Server.
|
@@ -109,13 +111,13 @@ Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ord
**Host-Metriken:**
-CPU-, Arbeitsspeicher- und Festplattenauslastung, Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor, Log-Viewer, Benutzer/Berechtigungen, Zertifikate und vieles mehr auf den meisten Linux-basierten Servern anzeigen.
+CPU-, Arbeitsspeicher- und Festplattenauslastung, Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor, Log-Viewer, Benutzer/Berechtigungen, Zertifikate und vieles mehr anzeigen, was auf den meisten Linux-basierten Servern funktioniert. Enthalt Zeitreihen-Verlaufsdiagramme und schwellenwertbasierte Warnmeldungen mit ntfy- und Webhook-Unterstutzung.
|
**Benutzerauthentifizierung:**
-Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC-/LDAP-/SSO-Unterstutzung (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen. Audit-Protokoll aller Benutzeraktionen anzeigen.
+Sichere Benutzerverwaltung mit Admin-Kontrollen (kann Informationen anderer Benutzer bearbeiten) und OIDC-/LDAP-/SSO-Unterstutzung (mit Zugriffskontrolle), 2FA (TOTP) und Passkey (WebAuthn)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen. Audit-Protokoll aller Benutzeraktionen anzeigen.
|
@@ -128,8 +130,8 @@ Gerate aus Ihrem Tailnet auflisten, um sie schnell als Hosts hinzuzufugen, und m
-**RBAC:**
-Rollen erstellen und Hosts uber Benutzer/Rollen teilen.
+**RBAC/Freigabe:**
+Erstellen Sie Rollen und teilen Sie Hosts uber Benutzer/Rollen hinweg. Unterstutzt alle Authentifizierungstypen und alle Host-Protokolle.
|
@@ -206,7 +208,8 @@ Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu mussen
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
- **Proxmox-Integration** - Automatisches Hinzufugen von Hosts zu Termix aus Ihrer Proxmox-Instanz
-- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking, Terminal-Protokollierung usw.
+- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking, Terminal-Protokollierung, SSH-Agent-Forwarding, Bitwarden SSH-Agent, HashiCorp Vault SSH-Signierung und mehr.
+- **Termix ID** - Ein sshid.io-Aquivalent, integriert in Termix. Beanspruchen Sie einen Handle, veroffentlichen Sie Ihre offentlichen SSH-Schlussel unter einer Resolver-URL und nutzen Sie eine integrierte CA zur Ausstellung von SSH-Zertifikaten.
@@ -249,7 +252,9 @@ Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://
## Installation
-Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) fur weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei (Sie konnen guacd und das Netzwerk weglassen, wenn Sie keine Remote-Desktop-Funktionen nutzen mochten):
+Besuchen Sie die [Termix-Dokumentation](https://docs.termix.site/install) fur vollstandige Installationsanleitungen fur alle Plattformen.
+
+Beispiel einer Docker-Compose-Datei (Sie konnen `guacd` und das Netzwerk weglassen, wenn Sie keine Remote-Desktop-Funktionen nutzen mochten):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Spenden
-Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken.
+Termix ist kostenlos und Open Source, ohne Abonnements oder kostenpflichtige Plane. Wenn Sie es nutzlich finden, erwagen Sie eine Spende, um Serverkosten, Domains und Entwicklungszeit zu decken. Spenden helfen auch dabei, die Zeit zu finanzieren, die benotigt wird, um zu erforschen und zu lernen, was fur Funktionen wie SAML-, Kubernetes- und Agent-Unterstutzung erforderlich ist. Verfolgen Sie den Fortschritt und spenden Sie unten.
-
+[Spenden](https://donate.termix.site/)
+
+
+
+## Sponsoren
+
+Interessiert an einer bezahlten Platzierung zur Unterstutzung der Entwicklung? Schreiben Sie eine E-Mail an [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Support
+
+Wenn Sie Hilfe benotigen oder eine Funktion fur Termix anfragen mochten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. Bitte beschreiben Sie Ihr Anliegen so detailliert wie moglich, vorzugsweise auf Englisch. Sie konnen auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings konnen die Antwortzeiten dort langer sein.
@@ -356,50 +411,6 @@ Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/5) fur alle geplant
-## Sponsoren
-
-
-
-
-
-## Support
-
-Wenn Sie Hilfe benotigen oder eine Funktion fur Termix anfragen mochten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. Bitte beschreiben Sie Ihr Anliegen so detailliert wie moglich, vorzugsweise auf Englisch. Sie konnen auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings konnen die Antwortzeiten dort langer sein.
-
-
-
## Lizenz
Verteilt unter der Apache License Version 2.0. Siehe `LICENSE` fur weitere Informationen.
diff --git a/readme/README-ES.md b/docs/readme/README-ES.md
similarity index 82%
rename from readme/README-ES.md
rename to docs/readme/README-ES.md
index 041d3f15..b2689408 100644
--- a/readme/README-ES.md
+++ b/docs/readme/README-ES.md
@@ -4,7 +4,7 @@
Termix
-Gestion SSH autoalojada y acceso a escritorio remoto
+Gestión SSH autoalojada y acceso a escritorio remoto
English ·
@@ -31,12 +31,14 @@
+
+
+
+
Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo.
-
-
@@ -56,7 +58,7 @@ Termix es gratuito y de código abierto. Si lo encuentras útil, considera [dona
## Descripcion General
-Termix es una plataforma de gestion de servidores todo en uno, de codigo abierto, siempre gratuita y autoalojada. Proporciona una solucion multiplataforma para gestionar sus servidores e infraestructura a traves de una interfaz unica e intuitiva. Termix ofrece acceso a terminal SSH, control de escritorio remoto (RDP, VNC, Telnet), capacidades de tuneles SSH, gestion remota de archivos SSH y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas.
+Termix es una plataforma de gestion de servidores todo en uno, de codigo abierto, siempre gratuita y autoalojada. Proporciona una solucion multiplataforma para gestionar sus servidores e infraestructura a traves de una interfaz unica e intuitiva. Termix ofrece acceso a terminal SSH, control de escritorio remoto (RDP, VNC, Telnet), capacidades de tuneles SSH, gestion remota de archivos y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas.
@@ -81,13 +83,13 @@ Soporte RDP, VNC y Telnet a traves del navegador con personalizacion completa y
**Gestion de Tuneles SSH:**
-Cree y gestione tuneles SSH de servidor a servidor con reconexion automatica, monitoreo de estado y reenvio local, remoto o dinamico SOCKS. La configuracion de tuneles de cliente de escritorio a servidor se almacena localmente por instalacion de escritorio; los snapshots de presets C2S opcionales pueden guardarse en el servidor, renombrarse, cargarse o eliminarse para mover una configuracion de tunel local entre clientes.
+Cree y gestione tuneles SSH de servidor a servidor con reconexion automatica, monitoreo de estado y reenvio local, remoto o dinamico SOCKS. La configuracion de tuneles de cliente de escritorio a servidor se almacena localmente por instalacion de escritorio, los snapshots de presets C2S opcionales pueden guardarse en el servidor, renombrarse, cargarse o eliminarse cuando desee mover una configuracion de tunel local entre clientes.
|
**Gestor Remoto de Archivos:**
-Gestione archivos directamente en servidores remotos con soporte para visualizar y editar codigo, imagenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
+Gestione archivos directamente en servidores remotos con soporte para visualizar y editar codigo, imagenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo. Incluye soporte para mover archivos de servidor a servidor.
|
@@ -109,13 +111,13 @@ Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas (con per
**Metricas del Host:**
-Vea el uso de CPU, memoria y disco, red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos, visor de registros, usuarios/permisos, certificados y muchos mas en la mayoria de los servidores basados en Linux.
+Vea el uso de CPU, memoria y disco, red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos, visor de registros, usuarios/permisos, certificados y muchos mas, que funcionan en la mayoria de los servidores basados en Linux. Incluye graficos de historial de series temporales y alertas basadas en umbrales con soporte para ntfy y webhooks.
|
**Autenticacion de Usuarios:**
-Gestion segura de usuarios con controles de administrador y soporte para OIDC/LDAP/SSO (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si. Vea el registro de auditoria de las acciones de todos los usuarios.
+Gestion segura de usuarios con controles de administrador (puede editar la informacion de otros usuarios) y soporte para OIDC/LDAP/SSO (con control de acceso), 2FA (TOTP) y soporte para passkeys (WebAuthn). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si. Vea el registro de auditoria de las acciones de todos los usuarios.
|
@@ -128,8 +130,8 @@ Liste dispositivos de su red Tailscale para agregarlos rapidamente como hosts y
-**RBAC:**
-Cree roles y comparta hosts entre usuarios/roles.
+**RBAC/Compartir:**
+Cree roles y comparta hosts entre usuarios/roles. Compatible con todos los tipos de autenticacion y todos los protocolos de host.
|
@@ -206,7 +208,8 @@ Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](http
- **Conexion Rapida** - Conectese a un servidor sin necesidad de guardar los datos de conexion
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rapidamente a las conexiones SSH con su teclado
- **Integracion con Proxmox** - Agregue automaticamente hosts a Termix desde su instancia de Proxmox
-- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
+- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, reenvio de agente SSH, agente SSH de Bitwarden, firma SSH con HashiCorp Vault y mas.
+- **Termix ID** - Un equivalente a sshid.io integrado en Termix. Reclame un identificador, publique sus claves publicas SSH en una URL de resolucion y use una CA integrada para emitir certificados SSH.
@@ -249,7 +252,9 @@ Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](http
## Instalacion
-Visite la [documentacion](https://docs.termix.site/install) de Termix para mas informacion sobre como instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aqui (puede omitir guacd y la red si no planea usar funciones de escritorio remoto):
+Visite la [documentacion de Termix](https://docs.termix.site/install) para obtener instrucciones completas de instalacion en todas las plataformas.
+
+Archivo de ejemplo de Docker Compose (puede omitir `guacd` y la red si no planea usar las funciones de escritorio remoto):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Donar
-Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo.
+Termix es gratuito y de codigo abierto, sin suscripciones ni planes de pago. Si lo encuentra util, considere donar para ayudar a cubrir los costos del servidor, los dominios y el tiempo de desarrollo. Las donaciones tambien ayudan a financiar el tiempo necesario para investigar y aprender lo que se necesita para construir funciones como soporte para SAML, Kubernetes y Agent. Siga el progreso y done a continuacion.
-
+[Donar](https://donate.termix.site/)
+
+
+
+## Patrocinadores
+
+Interesado en un espacio patrocinado de pago para apoyar el desarrollo? Escriba a [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Soporte
+
+Si necesita ayuda o desea solicitar una funcion para Termix, visite la pagina de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesion y pulse `New Issue`. Por favor, sea lo mas detallado posible en su reporte, preferiblemente escrito en ingles. Tambien puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser mas largos.
@@ -356,50 +411,6 @@ Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/5) para todas l
-## Patrocinadores
-
-
-
-
-
-## Soporte
-
-Si necesita ayuda o desea solicitar una funcion para Termix, visite la pagina de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesion y pulse `New Issue`. Por favor, sea lo mas detallado posible en su reporte, preferiblemente escrito en ingles. Tambien puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser mas largos.
-
-
-
## Licencia
Distribuido bajo la Licencia Apache Version 2.0. Consulte `LICENSE` para mas informacion.
diff --git a/readme/README-FR.md b/docs/readme/README-FR.md
similarity index 85%
rename from readme/README-FR.md
rename to docs/readme/README-FR.md
index c32ae6ba..0813165a 100644
--- a/readme/README-FR.md
+++ b/docs/readme/README-FR.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement.
-
-
@@ -87,7 +89,7 @@ Creez et gerez des tunnels SSH de serveur a serveur avec reconnexion automatique
**Gestionnaire de fichiers distant:**
-Gerez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'edition de code, images, audio et video. Televersez, telechargez, renommez, supprimez et deplacez des fichiers de maniere fluide avec support sudo.
+Gerez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'edition de code, images, audio et video. Televersez, telechargez, renommez, supprimez et deplacez des fichiers de maniere fluide avec support sudo. Inclut la prise en charge du deplacement de fichiers de serveur a serveur.
|
@@ -109,13 +111,13 @@ Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers
**Metriques d'hote:**
-Visualisez l'utilisation du CPU, de la memoire, du disque, le reseau, le temps de fonctionnement, les informations systeme, le pare-feu, le moniteur de ports, le visualiseur de journaux, les utilisateurs/permissions, les certificats et bien plus encore sur la plupart des serveurs Linux.
+Visualisez l'utilisation du CPU, de la memoire, du disque, le reseau, le temps de fonctionnement, les informations systeme, le pare-feu, le moniteur de ports, le visualiseur de journaux, les utilisateurs/permissions, les certificats et bien plus encore sur la plupart des serveurs Linux. Inclut des graphiques d'historique en serie temporelle et des alertes basees sur des seuils avec support ntfy et webhook.
|
**Authentification des utilisateurs:**
-Gestion securisee des utilisateurs avec controles administrateur et support OIDC/LDAP/SSO (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble. Consultez le journal d'audit des actions de tous les utilisateurs.
+Gestion securisee des utilisateurs avec controles administrateur (peut modifier les informations des autres utilisateurs) et support OIDC/LDAP/SSO (avec controle d'acces), 2FA (TOTP), et support des passkeys (WebAuthn). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble. Consultez le journal d'audit des actions de tous les utilisateurs.
|
@@ -128,8 +130,8 @@ Listez les appareils de votre reseau Tailscale pour les ajouter rapidement comme
-**RBAC:**
-Creez des roles et partagez des hotes entre utilisateurs/roles.
+**RBAC/Partage:**
+Creez des roles et partagez des hotes entre utilisateurs/roles. Prend en charge tous les types d'authentification et tous les protocoles d'hote.
|
@@ -206,7 +208,8 @@ Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.sit
- **Connexion rapide** - Connectez-vous a un serveur sans avoir a sauvegarder les donnees de connexion
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour acceder rapidement aux connexions SSH avec votre clavier
- **Integration Proxmox** - Ajoutez automatiquement des hotes dans Termix depuis votre instance Proxmox
-- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, journalisation du terminal, etc.
+- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, journalisation du terminal, transfert d'agent SSH, agent SSH Bitwarden, signature SSH HashiCorp Vault, et plus encore.
+- **Termix ID** - Un equivalent de sshid.io integre a Termix. Reservez un identifiant, publiez vos cles SSH publiques a une URL de resolution, et utilisez une autorite de certification integree pour emettre des certificats SSH.
@@ -249,7 +252,9 @@ Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.sit
## Installation
-Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Voici un exemple de fichier Docker Compose (vous pouvez omettre guacd et le reseau si vous ne prevoyez pas d'utiliser les fonctionnalites de bureau a distance) :
+Visitez la [documentation](https://docs.termix.site/install) de Termix pour des instructions d'installation completes sur toutes les plateformes.
+
+Voici un exemple de fichier Docker Compose (vous pouvez omettre guacd et le reseau si vous ne prevoyez pas d'utiliser les fonctionnalites de bureau a distance) :
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Faire un don
-Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement.
+Termix est gratuit et open source, sans abonnement ni plan payant. Si vous le trouvez utile, pensez a faire un don pour aider a couvrir les couts de serveur, les domaines et le temps de developpement. Les dons contribuent egalement a financer le temps necessaire pour rechercher et apprendre ce qui est requis pour construire des fonctionnalites comme SAML, Kubernetes et le support des agents. Suivez la progression et faites un don ci-dessous.
-
+[Faire un don](https://donate.termix.site/)
+
+
+
+## Sponsors
+
+Interesse par un placement payant pour soutenir le developpement ? Envoyez un email a [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Support
+
+Si vous avez besoin d'aide ou souhaitez demander une fonctionnalite pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez etre aussi detaille que possible dans votre issue, de preference redigee en anglais. Vous pouvez egalement rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de reponse peuvent etre plus longs.
@@ -356,50 +411,6 @@ Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/5) pour tou
-## Sponsors
-
-
-
-
-
-## Support
-
-Si vous avez besoin d'aide ou souhaitez demander une fonctionnalite pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez etre aussi detaille que possible dans votre issue, de preference redigee en anglais. Vous pouvez egalement rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de reponse peuvent etre plus longs.
-
-
-
## Licence
Distribue sous la licence Apache Version 2.0. Consultez `LICENSE` pour plus d'informations.
diff --git a/readme/README-HI.md b/docs/readme/README-HI.md
similarity index 80%
rename from readme/README-HI.md
rename to docs/readme/README-HI.md
index d88917de..17a1e63f 100644
--- a/readme/README-HI.md
+++ b/docs/readme/README-HI.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
-
-
@@ -56,7 +58,7 @@ Termix मुफ़्त और ओपन सोर्स है। यदि
## अवलोकन
-Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, रिमोट डेस्कटॉप कंट्रोल (RDP, VNC, Telnet), SSH टनलिंग क्षमताएँ, रिमोट SSH फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है।
+Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, रिमोट डेस्कटॉप कंट्रोल (RDP, VNC, Telnet), SSH टनलिंग क्षमताएँ, रिमोट फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है।
@@ -81,13 +83,13 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
**SSH टनल प्रबंधन:**
-ऑटोमैटिक रीकनेक्शन, हेल्थ मॉनिटरिंग और लोकल, रिमोट या डायनेमिक SOCKS फॉरवर्डिंग के साथ सर्वर-टु-सर्वर SSH टनल बनाएँ और प्रबंधित करें। डेस्कटॉप क्लाइंट-टु-सर्वर टनल सेटिंग्स प्रत्येक डेस्कटॉप इंस्टॉल में स्थानीय रूप से संग्रहीत होती हैं; वैकल्पिक C2S प्रीसेट स्नैपशॉट सर्वर पर सेव, रीनेम, लोड या डिलीट किए जा सकते हैं।
+ऑटोमैटिक रीकनेक्शन, हेल्थ मॉनिटरिंग और लोकल, रिमोट या डायनेमिक SOCKS फॉरवर्डिंग के साथ सर्वर-टु-सर्वर SSH टनल बनाएँ और प्रबंधित करें। डेस्कटॉप क्लाइंट-टु-सर्वर टनल सेटिंग्स प्रत्येक डेस्कटॉप इंस्टॉल में स्थानीय रूप से संग्रहीत होती हैं; वैकल्पिक C2S प्रीसेट स्नैपशॉट सर्वर पर सेव किए जा सकते हैं, तथा जब आप किसी लोकल टनल कॉन्फ़िगरेशन को क्लाइंट के बीच स्थानांतरित करना चाहें तो उन्हें रीनेम, लोड या डिलीट किया जा सकता है।
|
**रिमोट फ़ाइल मैनेजर:**
-कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें।
+कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें। इसमें फ़ाइलों को एक सर्वर से दूसरे सर्वर में स्थानांतरित करने का सपोर्ट भी शामिल है।
|
@@ -109,13 +111,13 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
**होस्ट मेट्रिक्स:**
-अधिकांश Linux आधारित सर्वर पर CPU, मेमोरी, डिस्क उपयोग, नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर, लॉग व्यूअर, उपयोगकर्ता/अनुमतियाँ, सर्टिफ़िकेट और भी बहुत कुछ देखें।
+अधिकांश Linux आधारित सर्वर पर CPU, मेमोरी, डिस्क उपयोग, नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर, लॉग व्यूअर, उपयोगकर्ता/अनुमतियाँ, सर्टिफ़िकेट और भी बहुत कुछ देखें। इसमें टाइम-सीरीज़ हिस्ट्री ग्राफ़ और ntfy व webhook सपोर्ट के साथ थ्रेशोल्ड-आधारित अलर्ट शामिल हैं।
|
**उपयोगकर्ता प्रमाणीकरण:**
-व्यवस्थापक नियंत्रण और OIDC/LDAP/SSO (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। सभी उपयोगकर्ताओं की कार्रवाइयों का ऑडिट लॉग देखें।
+व्यवस्थापक नियंत्रण (अन्य उपयोगकर्ताओं की जानकारी संपादित कर सकते हैं) और OIDC/LDAP/SSO (एक्सेस कंट्रोल के साथ), 2FA (TOTP), और पासकी (WebAuthn) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। सभी उपयोगकर्ताओं की कार्रवाइयों का ऑडिट लॉग देखें।
|
@@ -128,8 +130,8 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
-**RBAC:**
-भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
+**RBAC/शेयरिंग:**
+भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें। सभी प्रमाणीकरण प्रकारों और सभी होस्ट प्रोटोकॉल का सपोर्ट करता है।
|
@@ -206,7 +208,8 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
- **Proxmox एकीकरण** - अपने Proxmox इंस्टेंस से Termix में होस्ट स्वचालित रूप से जोड़ें
-- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग, टर्मिनल लॉगिंग आदि का सपोर्ट
+- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग, टर्मिनल लॉगिंग, SSH एजेंट फ़ॉरवर्डिंग, Bitwarden SSH एजेंट, HashiCorp Vault SSH सिग्निंग, और अन्य का सपोर्ट।
+- **Termix ID** - Termix में बिल्ट-इन sshid.io के समकक्ष। एक हैंडल क्लेम करें, अपनी सार्वजनिक SSH कुंजियों को एक रिज़ॉल्वर URL पर प्रकाशित करें, और SSH सर्टिफ़िकेट जारी करने के लिए बिल्ट-इन CA का उपयोग करें।
@@ -249,7 +252,9 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
## इंस्टॉलेशन
-सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। यहाँ एक नमूना Docker Compose फ़ाइल देखें (यदि आप रिमोट डेस्कटॉप सुविधाओं का उपयोग करने की योजना नहीं बना रहे हैं तो आप guacd और नेटवर्क को हटा सकते हैं):
+सभी प्लेटफ़ॉर्म पर पूर्ण इंस्टॉलेशन निर्देशों के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ।
+
+नमूना Docker Compose फ़ाइल (यदि आप रिमोट डेस्कटॉप सुविधाओं का उपयोग करने की योजना नहीं बना रहे हैं तो आप `guacd` और नेटवर्क को हटा सकते हैं):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## दान करें
-Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
+Termix मुफ़्त और ओपन सोर्स है, बिना किसी सब्सक्रिप्शन या पेड प्लान के। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत, डोमेन और विकास समय को कवर करने में मदद के लिए दान करने पर विचार करें। दान SAML, Kubernetes, और Agent सपोर्ट जैसी सुविधाओं के निर्माण के लिए आवश्यक शोध और सीखने में लगने वाले समय को वित्त पोषित करने में भी मदद करते हैं। नीचे प्रगति देखें और दान करें।
-
+[दान करें](https://donate.termix.site/)
+
+
+
+## प्रायोजक
+
+विकास को समर्थन देने के लिए पेड प्लेसमेंट में रुचि है? [mail@termix.site](mailto:mail@termix.site) पर ईमेल करें।
+
+
+
+
+
+## सहायता
+
+यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है।
@@ -356,50 +411,6 @@ Termix मुफ़्त और ओपन सोर्स है। यदि
-## प्रायोजक
-
-
-
-
-
-## सहायता
-
-यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है।
-
-
-
## लाइसेंस
Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए `LICENSE` देखें।
diff --git a/readme/README-IT.md b/docs/readme/README-IT.md
similarity index 71%
rename from readme/README-IT.md
rename to docs/readme/README-IT.md
index 278430ae..91d6884b 100644
--- a/readme/README-IT.md
+++ b/docs/readme/README-IT.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo.
-
-
@@ -56,11 +58,11 @@ Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https
## Panoramica
-Termix e una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, controllo remoto del desktop (RDP, VNC, Telnet), funzionalita di tunneling SSH, gestione remota dei file SSH e molti altri strumenti. Termix e la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme.
+Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, controllo remoto del desktop (RDP, VNC, Telnet), funzionalità di tunneling SSH, gestione remota dei file e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme.
-## Funzionalita
+## Funzionalità
@@ -87,7 +89,7 @@ Crea e gestisci tunnel SSH da server a server con riconnessione automatica, moni
|
**Gestore File Remoto:**
-Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
+Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo. Include il supporto per spostare file da server a server.
|
@@ -95,7 +97,7 @@ Gestisci i file direttamente sui server remoti con supporto per la visualizzazio
**Gestione Docker e Podman:**
-Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Supporta sia Docker che Podman come runtime dei container. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
+Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Supporta sia Docker che Podman come runtime dei container. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
|
@@ -109,13 +111,13 @@ Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle (con perso
|
**Metriche Host:**
-Visualizza l'utilizzo di CPU, memoria, disco, rete, uptime, informazioni di sistema, firewall, monitoraggio porte, visualizzatore di log, utenti/permessi, certificati e molto altro sulla maggior parte dei server basati su Linux.
+Visualizza CPU, memoria, utilizzo del disco, rete, uptime, informazioni di sistema, firewall, monitoraggio porte, visualizzatore di log, utenti/permessi, certificati e molto altro, funzionanti sulla maggior parte dei server basati su Linux. Include grafici storici delle serie temporali e avvisi basati su soglie con supporto ntfy e webhook.
|
**Autenticazione Utente:**
-Gestione utenti sicura con controlli amministrativi e supporto OIDC/LDAP/SSO (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. Visualizza il log di controllo delle azioni di tutti gli utenti.
+Gestione utenti sicura con controlli amministrativi (può modificare le informazioni di altri utenti) e OIDC/LDAP/SSO (con controllo degli accessi), 2FA (TOTP) e supporto passkey (WebAuthn). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. Visualizza il log di controllo delle azioni di tutti gli utenti.
|
@@ -128,8 +130,8 @@ Elenca i dispositivi della tua rete Tailscale per aggiungerli rapidamente come h
-**RBAC:**
-Crea ruoli e condividi host tra utenti/ruoli.
+**RBAC/Condivisione:**
+Crea ruoli e condividi host tra utenti/ruoli. Supporta tutti i tipi di autenticazione e tutti i protocolli host.
|
@@ -137,7 +139,7 @@ Crea ruoli e condividi host tra utenti/ruoli.
**Connessioni Seriali:**
-Connettiti a dispositivi seriali (router, switch, microcontrollori, ecc.) direttamente dal browser o dall'app desktop. Configura baud rate, bit di dati, bit di stop e parita. Utilizza la Web Serial API nei browser supportati o un backend nativo nell'app Electron.
+Connettiti a dispositivi seriali (router, switch, microcontrollori, ecc.) direttamente dal browser o dall'app desktop. Configura baud rate, bit di dati, bit di stop e parità. Utilizza la Web Serial API nei browser supportati o un backend nativo nell'app Electron.
|
@@ -157,7 +159,7 @@ Una homepage completamente personalizzabile con una griglia di widget drag-and-d
|
**Crittografia Database:**
-Il backend e archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni.
+Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni.
|
@@ -171,7 +173,7 @@ Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle conne
**Strumenti SSH:**
-Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su piu terminali aperti.
+Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti.
|
@@ -194,7 +196,7 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
-Altre funzionalita
+Altre funzionalità
- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard
@@ -206,7 +208,8 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
- **Integrazione Proxmox** - Aggiungi automaticamente host a Termix dalla tua istanza Proxmox
-- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registrazione terminale, ecc.
+- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registrazione terminale, SSH agent forwarding, Bitwarden SSH agent, firma SSH HashiCorp Vault e altro ancora
+- **Termix ID** - L'equivalente di sshid.io integrato in Termix. Rivendica un handle, pubblica le tue chiavi SSH pubbliche su un URL resolver e utilizza una CA integrata per emettere certificati SSH
@@ -225,7 +228,7 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
| Windows x64/ia32 |
-Portable · MSI Installer · Chocolatey |
+Portable · Installer MSI · Chocolatey |
| Linux x64/ia32 |
@@ -249,7 +252,9 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
## Installazione
-Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui (puoi omettere guacd e la rete se non prevedi di utilizzare le funzioni di desktop remoto):
+Visita la [Documentazione Termix](https://docs.termix.site/install) per le istruzioni complete di installazione su tutte le piattaforme.
+
+File Docker Compose di esempio (puoi omettere `guacd` e la rete se non prevedi di utilizzare le funzioni di desktop remoto):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Dona
-Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo.
+Termix è gratuito e open source, senza abbonamenti o piani a pagamento. Se lo trovi utile, considera di donare per aiutare a coprire i costi del server, i domini e il tempo di sviluppo. Le donazioni aiutano anche a finanziare il tempo necessario per ricercare e imparare ciò che serve per costruire funzionalità come SAML, Kubernetes e supporto Agent. Segui i progressi e dona qui sotto.
-
+[Dona](https://donate.termix.site/)
+
+
+
+## Sponsor
+
+Interessato a un posizionamento a pagamento per supportare lo sviluppo? Scrivi a [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Supporto
+
+Se hai bisogno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Issues](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi.
@@ -344,59 +399,15 @@ Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https
-Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalita.
+Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalità.
-## Funzionalita Pianificate
+## Funzionalità Pianificate
-Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/5) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
-
-
-
-## Sponsor
-
-
-
-
-
-## Supporto
-
-Se hai bisogno di aiuto o vuoi richiedere una funzionalita per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. Per favore, sii il piu dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere piu lunghi.
+Consulta [Projects](https://github.com/orgs/Termix-SSH/projects/5) per tutte le funzionalità pianificate. Se desideri contribuire, consulta [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-JA.md b/docs/readme/README-JA.md
similarity index 83%
rename from readme/README-JA.md
rename to docs/readme/README-JA.md
index 8980fcae..dbde0588 100644
--- a/readme/README-JA.md
+++ b/docs/readme/README-JA.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
-
-
@@ -56,7 +58,7 @@ Termix は無料のオープンソースプロジェクトです。便利だと
## 概要
-Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、リモートデスクトップ制御(RDP、VNC、Telnet)、SSHトンネリング機能、リモートSSHファイル管理、およびその他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。
+Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、リモートデスクトップ制御(RDP、VNC、Telnet)、SSHトンネリング機能、リモートファイル管理、およびその他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。
@@ -87,7 +89,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
**リモートファイルマネージャー:**
-コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。
+コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。サーバー間でのファイル移動にも対応しています。
|
@@ -109,13 +111,13 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
**ホストメトリクス:**
-ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニター、ログビューア、ユーザー/権限、証明書など、さらに多くの情報を表示できます。
+ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニター、ログビューア、ユーザー/権限、証明書など、さらに多くの情報を表示できます。時系列の履歴グラフと、ntfyおよびwebhookに対応したしきい値ベースのアラートを含みます。
|
**ユーザー認証:**
-管理者コントロールとOIDC/LDAP/SSO(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。すべてのユーザー操作の監査ログを表示できます。
+管理者コントロール(他のユーザー情報を編集可能)とOIDC/LDAP/SSO(アクセス制御付き)、2FA(TOTP)、パスキー(WebAuthn)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。すべてのユーザー操作の監査ログを表示できます。
|
@@ -123,13 +125,13 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
**Tailscaleインテグレーション:**
-TailnetのデバイスをリストしてホストとしてすばやくH追加し、Tailscale SSHを認証方法として使用して接続します。これにより、TailnetのACLが認証情報を保存せずに認可を処理します。
+Tailnetのデバイスをリストしてホストとしてすばやく追加し、Tailscale SSHを認証方法として使用して接続します。これにより、TailnetのACLが認証情報を保存せずに認可を処理します。
|
-**RBAC:**
-ロールを作成し、ユーザー/ロール間でホストを共有できます。
+**RBAC/共有:**
+ロールを作成し、ユーザー/ロール間でホストを共有できます。すべての認証タイプとすべてのホストプロトコルに対応しています。
|
@@ -206,7 +208,8 @@ TailnetのデバイスをリストしてホストとしてすばやくH追加し
- **クイック接続** - 接続データを保存せずにサーバーに接続できます
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます
- **Proxmox統合** - Proxmoxインスタンスからホストを自動的にTermixに追加できます
-- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)、ターミナルログ記録などに対応しています
+- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)、ターミナルログ記録、SSHエージェントフォワーディング、Bitwarden SSHエージェント、HashiCorp Vault SSH署名などに対応しています
+- **Termix ID** - Termixに組み込まれたsshid.io相当の機能です。ハンドルを取得し、リゾルバーURLで公開SSHキーを公開し、組み込みCAを使用してSSH証明書を発行できます。
@@ -249,7 +252,9 @@ TailnetのデバイスをリストしてホストとしてすばやくH追加し
## インストール
-すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。また、以下のサンプルDocker Composeファイルをご覧ください(リモートデスクトップ機能を使用する予定がない場合は、guacdとネットワークの設定を省略できます):
+すべてのプラットフォームへのTermixのインストール方法については、[Termixドキュメント](https://docs.termix.site/install)をご覧ください。
+
+サンプルDocker Composeファイル(リモートデスクトップ機能を使用する予定がない場合は、`guacd`とネットワークの設定を省略できます):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## 寄付
-Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
+Termixは無料のオープンソースプロジェクトであり、サブスクリプションや有料プランはありません。便利だと感じた場合は、サーバーコスト、ドメイン、開発時間を賄うための寄付をご検討ください。寄付は、SAML、Kubernetes、Agentサポートなどの機能を構築するために必要な調査と学習の時間を確保することにも役立ちます。以下で進捗を確認し、寄付できます。
-
+[寄付する](https://donate.termix.site/)
+
+
+
+## スポンサー
+
+開発を支援するための有料掲載にご興味がありますか?[mail@termix.site](mailto:mail@termix.site)までメールをお送りください。
+
+
+
+
+
+## サポート
+
+Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。
@@ -356,50 +411,6 @@ Termix は無料のオープンソースプロジェクトです。便利だと
-## スポンサー
-
-
-
-
-
-## サポート
-
-Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。
-
-
-
## ライセンス
Apache License Version 2.0のもとで配布されています。詳細は`LICENSE`をご覧ください。
diff --git a/readme/README-KO.md b/docs/readme/README-KO.md
similarity index 85%
rename from readme/README-KO.md
rename to docs/readme/README-KO.md
index 4fee63ca..8382f83b 100644
--- a/readme/README-KO.md
+++ b/docs/readme/README-KO.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
-
-
@@ -87,7 +89,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
**원격 파일 관리자:**
-코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행.
+코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행. 서버 간 파일 이동도 지원합니다.
|
@@ -109,13 +111,13 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
**호스트 메트릭:**
-대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량, 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터, 로그 뷰어, 사용자/권한, 인증서 등 다양한 정보를 표시.
+대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량, 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터, 로그 뷰어, 사용자/권한, 인증서 등 다양한 정보를 표시. 시계열 히스토리 그래프와 ntfy 및 웹훅을 지원하는 임계값 기반 알림을 포함합니다.
|
**사용자 인증:**
-관리자 제어와 OIDC/LDAP/SSO(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. 모든 사용자 작업의 감사 로그 조회.
+관리자 제어(다른 사용자 정보 편집 가능)와 OIDC/LDAP/SSO(액세스 제어 포함), 2FA(TOTP), 패스키(WebAuthn) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. 모든 사용자 작업의 감사 로그 조회.
|
@@ -128,8 +130,8 @@ Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가
-**RBAC:**
-역할을 생성하고 사용자/역할 간에 호스트 공유.
+**RBAC/공유:**
+역할을 생성하고 사용자/역할 간에 호스트를 공유합니다. 모든 인증 유형과 모든 호스트 프로토콜을 지원합니다.
|
@@ -206,7 +208,8 @@ Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
- **Proxmox 통합** - Proxmox 인스턴스에서 Termix로 호스트를 자동 추가
-- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹, 터미널 로깅 등 지원
+- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹, 터미널 로깅, SSH 에이전트 포워딩, Bitwarden SSH 에이전트, HashiCorp Vault SSH 서명 등 지원.
+- **Termix ID** - Termix에 내장된 sshid.io와 동등한 기능. 핸들을 등록하고, 리졸버 URL에 공개 SSH 키를 게시하며, 내장 CA를 사용하여 SSH 인증서를 발급할 수 있습니다.
@@ -249,7 +252,9 @@ Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가
## 설치
-모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다(원격 데스크톱 기능을 사용할 계획이 없다면 guacd와 네트워크를 생략할 수 있습니다):
+모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요.
+
+다음은 Docker Compose 파일 예시입니다(원격 데스크톱 기능을 사용할 계획이 없다면 guacd와 네트워크를 생략할 수 있습니다):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## 후원
-Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
+Termix는 구독이나 유료 요금제가 없는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용, 도메인, 개발 시간을 위해 후원을 고려해 주세요. 후원은 SAML, Kubernetes, 에이전트 지원과 같은 기능을 구축하는 데 필요한 사항을 연구하고 학습하는 시간에도 사용됩니다. 아래에서 진행 상황을 확인하고 후원할 수 있습니다.
-
+[후원하기](https://donate.termix.site/)
+
+
+
+## 스폰서
+
+개발 지원을 위한 유료 광고에 관심이 있으신가요? [mail@termix.site](mailto:mail@termix.site)로 이메일을 보내주세요.
+
+
+
+
+
+## 지원
+
+Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다.
@@ -356,50 +411,6 @@ Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고
-## 스폰서
-
-
-
-
-
-## 지원
-
-Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다.
-
-
-
## 라이선스
Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 `LICENSE`를 참조하세요.
diff --git a/readme/README-PT.md b/docs/readme/README-PT.md
similarity index 85%
rename from readme/README-PT.md
rename to docs/readme/README-PT.md
index 47656bbd..c46606cc 100644
--- a/readme/README-PT.md
+++ b/docs/readme/README-PT.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento.
-
-
@@ -81,13 +83,13 @@ Suporte a RDP, VNC e Telnet pelo navegador com personalizacao completa e tela di
**Gerenciamento de Tuneis SSH:**
-Crie e gerencie tuneis SSH de servidor para servidor com reconexao automatica, monitoramento de saude e encaminhamento local, remoto ou SOCKS dinamico. As configuracoes de tunel de cliente desktop para servidor sao armazenadas localmente por instalacao de desktop; snapshots de predefinicoes C2S opcionais podem ser salvos no servidor, renomeados, carregados ou excluidos para mover uma configuracao de tunel local entre clientes.
+Crie e gerencie tuneis SSH de servidor para servidor com reconexao automatica, monitoramento de saude e encaminhamento local, remoto ou SOCKS dinamico. As configuracoes de tunel de cliente desktop para servidor sao armazenadas localmente por instalacao de desktop, snapshots de predefinicoes C2S opcionais podem ser salvos no servidor, renomeados, carregados ou excluidos quando voce quiser mover uma configuracao de tunel local entre clientes.
|
**Gerenciador Remoto de Arquivos:**
-Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar codigo, imagens, audio e video. Faca upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
+Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar codigo, imagens, audio e video. Faca upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo. Inclui suporte para mover arquivos de servidor para servidor.
|
@@ -109,13 +111,13 @@ Salve, organize e gerencie suas conexoes SSH com tags e pastas (com personalizac
**Metricas do Host:**
-Visualize o uso de CPU, memoria e disco, rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas, visualizador de logs, usuarios/permissoes, certificados e muito mais na maioria dos servidores baseados em Linux.
+Visualize o uso de CPU, memoria e disco, rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas, visualizador de logs, usuarios/permissoes, certificados e muito mais na maioria dos servidores baseados em Linux. Inclui graficos de historico em serie temporal e alertas baseados em limites com suporte a ntfy e webhook.
|
**Autenticacao de Usuarios:**
-Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC/LDAP/SSO (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si. Visualize o log de auditoria de todas as acoes dos usuarios.
+Gerenciamento seguro de usuarios com controles de administrador (podem editar informacoes de outros usuarios) e suporte para OIDC/LDAP/SSO (com controle de acesso), 2FA (TOTP) e passkey (WebAuthn). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si. Visualize o log de auditoria de todas as acoes dos usuarios.
|
@@ -128,8 +130,8 @@ Liste dispositivos da sua rede Tailscale para adicioná-los rapidamente como hos
-**RBAC:**
-Crie funcoes e compartilhe hosts entre usuarios/funcoes.
+**RBAC/Compartilhamento:**
+Crie funcoes e compartilhe hosts entre usuarios/funcoes. Suporta todos os tipos de autenticacao e todos os protocolos de host.
|
@@ -206,7 +208,8 @@ Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](htt
- **Conexao Rapida** - Conecte-se a um servidor sem precisar salvar os dados de conexao
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexoes SSH com seu teclado
- **Integracao com Proxmox** - Adicione automaticamente hosts ao Termix a partir da sua instancia Proxmox
-- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
+- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, encaminhamento de agente SSH, agente SSH do Bitwarden, assinatura SSH do HashiCorp Vault, e mais.
+- **Termix ID** - Um equivalente ao sshid.io integrado ao Termix. Reivindique um identificador, publique suas chaves SSH publicas em uma URL de resolucao e use uma CA integrada para emitir certificados SSH.
@@ -249,7 +252,9 @@ Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](htt
## Instalacao
-Visite a [documentacao](https://docs.termix.site/install) do Termix para mais informacoes sobre como instalar o Termix em todas as plataformas. Caso contrario, veja um arquivo Docker Compose de exemplo aqui (voce pode omitir o guacd e a rede se nao planeja usar recursos de area de trabalho remota):
+Visite a [documentacao](https://docs.termix.site/install) do Termix para instrucoes completas de instalacao em todas as plataformas.
+
+Arquivo Docker Compose de exemplo (voce pode omitir o `guacd` e a rede se nao planeja usar recursos de area de trabalho remota):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Doar
-Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento.
+Termix e gratuito e de codigo aberto, sem assinaturas ou planos pagos. Se o achar util, considere doar para ajudar a cobrir custos de servidor, dominios e tempo de desenvolvimento. As doacoes tambem ajudam a financiar o tempo de pesquisa e aprendizado necessario para construir funcionalidades como suporte a SAML, Kubernetes e Agent. Acompanhe o progresso e doe abaixo.
-
+[Doar](https://donate.termix.site/)
+
+
+
+## Patrocinadores
+
+Interessado em um espaco pago para apoiar o desenvolvimento? Envie um email para [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Suporte
+
+Se voce precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a pagina de [Issues](https://github.com/Termix-SSH/Support/issues), faca login e clique em `New Issue`. Por favor, seja o mais detalhado possivel no seu relato, preferencialmente escrito em ingles. Voce tambem pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porem, os tempos de resposta podem ser mais longos.
@@ -356,50 +411,6 @@ Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/5) para todas as
-## Patrocinadores
-
-
-
-
-
-## Suporte
-
-Se voce precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a pagina de [Issues](https://github.com/Termix-SSH/Support/issues), faca login e clique em `New Issue`. Por favor, seja o mais detalhado possivel no seu relato, preferencialmente escrito em ingles. Voce tambem pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porem, os tempos de resposta podem ser mais longos.
-
-
-
## Licenca
Distribuido sob a Licenca Apache Versao 2.0. Consulte `LICENSE` para mais informacoes.
diff --git a/readme/README-RU.md b/docs/readme/README-RU.md
similarity index 84%
rename from readme/README-RU.md
rename to docs/readme/README-RU.md
index 2ed46ed4..7db61e9f 100644
--- a/readme/README-RU.md
+++ b/docs/readme/README-RU.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
-
-
@@ -81,13 +83,13 @@ Termix - это платформа для управления серверам
**Управление SSH-туннелями:**
-Создание и управление межсерверными SSH-туннелями с автоматическим переподключением, мониторингом состояния и локальной, удалённой или динамической SOCKS-переадресацией. Настройки туннелей «десктопный клиент - сервер» хранятся локально для каждой установки; опциональные снимки C2S-пресетов можно сохранять на сервере, переименовывать, загружать или удалять для переноса конфигурации между клиентами.
+Создание и управление межсерверными SSH-туннелями с автоматическим переподключением, мониторингом состояния и локальной, удалённой или динамической SOCKS-переадресацией. Настройки туннелей «десктопный клиент - сервер» хранятся локально для каждой установки; опциональные снимки C2S-пресетов можно сохранять на сервере, переименовывать, загружать или удалять, когда вы хотите перенести локальную конфигурацию туннеля между клиентами.
|
**Удалённый файловый менеджер:**
-Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo.
+Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo. Включает поддержку перемещения файлов с сервера на сервер.
|
@@ -109,13 +111,13 @@ Termix - это платформа для управления серверам
**Метрики хоста:**
-Просмотр использования CPU, памяти и диска, сети, времени работы, информации о системе, файрвола, монитора портов, просмотрщика логов, пользователей/прав доступа, сертификатов и многого другого на большинстве серверов на базе Linux.
+Просмотр использования CPU, памяти и диска, сети, времени работы, информации о системе, файрвола, монитора портов, просмотрщика логов, пользователей/прав доступа, сертификатов и многого другого на большинстве серверов на базе Linux. Включает графики истории временных рядов и оповещения на основе пороговых значений с поддержкой ntfy и вебхуков.
|
**Аутентификация пользователей:**
-Безопасное управление пользователями с административным контролем и поддержкой OIDC/LDAP/SSO (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. Просмотр журнала аудита действий всех пользователей.
+Безопасное управление пользователями с административным контролем (может редактировать информацию других пользователей) и поддержкой OIDC/LDAP/SSO (с контролем доступа), 2FA (TOTP) и поддержкой ключей доступа (WebAuthn). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. Просмотр журнала аудита действий всех пользователей.
|
@@ -128,8 +130,8 @@ Termix - это платформа для управления серверам
-**RBAC:**
-Создание ролей и предоставление общего доступа к хостам для пользователей/ролей.
+**RBAC/Общий доступ:**
+Создание ролей и предоставление общего доступа к хостам для пользователей/ролей. Поддерживает все типы аутентификации и все протоколы хостов.
|
@@ -206,7 +208,8 @@ SSH-сессии и вкладки остаются открытыми на вс
- **Быстрое подключение** - Подключение к серверу без необходимости сохранения данных подключения
- **Командная палитра** - Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
- **Интеграция с Proxmox** - Автоматическое добавление хостов в Termix из вашего экземпляра Proxmox
-- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, логирования терминала и др.
+- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, логирования терминала, переадресации SSH-агента, SSH-агента Bitwarden, подписи SSH через HashiCorp Vault и многого другого.
+- **Termix ID** - Аналог sshid.io, встроенный в Termix. Зарегистрируйте имя пользователя, опубликуйте свои публичные SSH-ключи по URL резолвера и используйте встроенный ЦС для выдачи SSH-сертификатов.
@@ -249,7 +252,9 @@ SSH-сессии и вкладки остаются открытыми на вс
## Установка
-Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь (вы можете опустить guacd и сеть, если не планируете использовать функции удаленного рабочего стола):
+Посетите [документацию](https://docs.termix.site/install) Termix для получения полных инструкций по установке на всех платформах.
+
+Пример файла Docker Compose (вы можете опустить `guacd` и сеть, если не планируете использовать функции удаленного рабочего стола):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Пожертвование
-Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
+Termix бесплатен и имеет открытый исходный код, без подписок или платных тарифов. Если он вам полезен, рассмотрите возможность пожертвования, чтобы помочь покрыть расходы на серверы, домены и время разработки. Пожертвования также помогают финансировать время на исследование и изучение того, что необходимо для создания таких функций, как поддержка SAML, Kubernetes и Agent. Отслеживайте прогресс и делайте пожертвования ниже.
-
+[Пожертвовать](https://donate.termix.site/)
+
+
+
+## Спонсоры
+
+Заинтересованы в платном размещении для поддержки разработки? Напишите на [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Поддержка
+
+Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше.
@@ -356,50 +411,6 @@ Termix — бесплатный проект с открытым исходны
-## Спонсоры
-
-
-
-
-
-## Поддержка
-
-Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше.
-
-
-
## Лицензия
Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле `LICENSE`.
diff --git a/readme/README-TR.md b/docs/readme/README-TR.md
similarity index 81%
rename from readme/README-TR.md
rename to docs/readme/README-TR.md
index 571d0cc8..1ee719b0 100644
--- a/readme/README-TR.md
+++ b/docs/readme/README-TR.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz.
-
-
@@ -56,7 +58,7 @@ Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyet
## Genel Bakis
-Termix, acik kaynakli, sonsuza kadar ucretsiz, kendi sunucunuzda barindirabileceginez hepsi bir arada sunucu yonetim platformudur. Sunucularinizi ve altyapinizi tek bir sezgisel arayuz uzerinden yonetmek icin cok platformlu bir cozum sunar. Termix, SSH terminal erisimi, uzak masaustu kontrolu (RDP, VNC, Telnet), SSH tunelleme yetenekleri, uzak SSH dosya yonetimi ve daha bircok arac saglar. Termix, tum platformlarda kullanilabilen Termius'un mukemmel ucretsiz ve kendi barindirmali alternatifidir.
+Termix, acik kaynakli, sonsuza kadar ucretsiz, kendi sunucunuzda barindirabileceginez hepsi bir arada sunucu yonetim platformudur. Sunucularinizi ve altyapinizi tek bir sezgisel arayuz uzerinden yonetmek icin cok platformlu bir cozum sunar. Termix, SSH terminal erisimi, uzak masaustu kontrolu (RDP, VNC, Telnet), SSH tunelleme yetenekleri, uzak dosya yonetimi ve daha bircok arac saglar. Termix, tum platformlarda kullanilabilen Termius'un mukemmel ucretsiz ve kendi barindirmali alternatifidir.
@@ -81,13 +83,13 @@ Tam ozellestirme ve bolunmus ekran ile tarayici uzerinden RDP, VNC ve Telnet des
**SSH Tunel Yonetimi:**
-Otomatik yeniden baglantiya, saglik izleme ve yerel, uzak veya dinamik SOCKS yonlendirme destegi ile sunucular arasi SSH tunelleri olusturun ve yonetin. Masaustu istemci-sunucu tunel ayarlari her masaustu kurulumu icin yerel olarak depolanir; istege bagli C2S hazir ayar anlik goruntuleri sunucuya kaydedilebilir, yeniden adlandirilabilir, yuklenebilir veya silinebilir.
+Otomatik yeniden baglanti, saglik izleme ve yerel, uzak veya dinamik SOCKS yonlendirme ile sunucular arasi SSH tunelleri olusturun ve yonetin. Masaustu istemci-sunucu tunel ayarlari her masaustu kurulumu icin yerel olarak depolanir; istege bagli C2S hazir ayar anlik goruntuleri, yerel bir tunel yapilandirmasini istemciler arasinda tasimak istediginizde sunucuya kaydedilebilir, yeniden adlandirilabilir, yuklenebilir veya silinebilir.
|
**Uzak Dosya Yoneticisi:**
-Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video goruntuleme ve duzenleme destegi ile. Sudo destegi ile dosyalari sorunsuzca yukleyin, indirin, yeniden adlandirin, silin ve tasiyin.
+Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video goruntuleme ve duzenleme destegi ile. Sudo destegi ile dosyalari sorunsuzca yukleyin, indirin, yeniden adlandirin, silin ve tasiyin. Dosyalari sunucudan sunucuya tasima destegini de icerir.
|
@@ -109,13 +111,13 @@ SSH baglantilarinizi etiketler ve klasorlerle (klasor ozellestirme ve ic ice kla
**Ana Bilgisayar Metrikleri:**
-Cogu Linux tabanli sunucularda CPU, bellek, disk kullanimi, ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme, gunluk goruntuleyici, kullanicilar/izinler, sertifikalar ve daha fazlasini goruntuleyin.
+Cogu Linux tabanli sunucularda calisan CPU, bellek, disk kullanimi, ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme, gunluk goruntuleyici, kullanicilar/izinler, sertifikalar ve daha fazlasini goruntuleyin. Zaman serisi gecmis grafiklerini ve ntfy ile webhook destekli esik tabanli uyarilari icerir.
|
**Kullanici Kimlik Dogrulama:**
-Yonetici kontrolleri, OIDC/LDAP/SSO (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin. Tum kullanicilarin islemlerinin denetim gunlugunu goruntuleyin.
+Yonetici kontrolleri (diger kullanicilarin bilgilerini duzenleyebilir), OIDC/LDAP/SSO (erisim kontrollu), 2FA (TOTP) ve passkey (WebAuthn) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin. Tum kullanicilarin islemlerinin denetim gunlugunu goruntuleyin.
|
@@ -128,8 +130,8 @@ Tailscale aginizdaki cihazlari listeleyerek hizlica ana bilgisayar olarak ekleyi
-**RBAC:**
-Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin.
+**RBAC/Paylasim:**
+Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin. Tum kimlik dogrulama turlerini ve tum ana bilgisayar protokollerini destekler.
|
@@ -151,7 +153,7 @@ Ana bilgisayar metrikleri (CPU, bellek, disk vb.) icin esik tabanli uyari kurall
**Ana Sayfa:**
-Surukleme ve birakma widget izgarasina sahip tamamen ozerlestirilebilir bir ana sayfa. Ana bilgisayar durumu, hizmet baglantilari, saatler, notlar, RSS besleme, hava durumu, Docker konteynerleri, ana bilgisayar metrik grafikleri, gomulu terminaller, iframe ve daha fazlasi icin widget ekleyin.
+Surukleme ve birakma widget izgarasina sahip tamamen ozellestirilebilir bir ana sayfa. Ana bilgisayar durumu, hizmet baglantilari, saatler, notlar, RSS besleme, hava durumu, Docker konteynerleri, ana bilgisayar metrik grafikleri, gomulu terminaller, iframe ve daha fazlasi icin widget ekleyin.
|
@@ -206,7 +208,8 @@ Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/transla
- **Hizli Baglanti** - Baglanti verilerini kaydetmeden bir sunucuya baglanin
- **Komut Paleti** - Sol shift tusuna iki kez basarak SSH baglantilariniza klavyenizle hizlica erisin
- **Proxmox Entegrasyonu** - Proxmox ornekinizden Termix'e otomatik olarak ana bilgisayar ekleyin
-- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal gunlukleme vb. destekler.
+- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal gunlukleme, SSH agent forwarding, Bitwarden SSH agent, HashiCorp Vault SSH imzalama ve dahasini destekler.
+- **Termix ID** - Termix'e entegre edilmis bir sshid.io esdegeri. Bir kullanici adi edinin, genel SSH anahtarlarinizi bir cozumleyici URL'sinde yayinlayin ve SSH sertifikalari vermek icin yerlesik bir CA kullanin.
@@ -249,7 +252,9 @@ Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/transla
## Kurulum
-Termix'i tum platformlara nasil kuracaginiz hakkinda daha fazla bilgi icin Termix [Belgelerine](https://docs.termix.site/install) bakin. Ornek bir Docker Compose dosyasini asagida inceleyebilirsiniz (uzak masaustu ozelliklerini kullanmayi planlamiyorsaniz guacd'yi ve agi cikarabilirsiniz):
+Termix'i tum platformlara nasil kuracaginiz hakkinda daha fazla bilgi icin Termix [Belgelerine](https://docs.termix.site/install) bakin.
+
+Ornek bir Docker Compose dosyasi (uzak masaustu ozelliklerini kullanmayi planlamiyorsaniz `guacd` ve agi cikarabilirsiniz):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Bağış Yapın
-Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz.
+Termix ücretsiz ve açık kaynaklıdır, abonelik veya ücretli plan yoktur. Faydalı buluyorsaniz, sunucu maliyetleri, alan adlari ve gelistirme suresine katkida bulunmak icin bagis yapmayi dusunebilirsiniz. Bagislar ayrica SAML, Kubernetes ve Agent destegi gibi ozellikleri gelistirmek icin gereken arastirma ve ogrenme suresini finanse etmeye yardimci olur. Ilerlemeyi takip edin ve asagidan bagis yapin.
-
+[Bağış Yapın](https://donate.termix.site/)
+
+
+
+## Sponsorlar
+
+Gelistirmeyi desteklemek icin ucretli bir yerlesim ile ilgileniyor musunuz? [mail@termix.site](mailto:mail@termix.site) adresine e-posta gonderin.
+
+
+
+
+
+## Destek
+
+Termix ile ilgili yardima ihtiyaciniz varsa veya bir ozellik talep etmek istiyorsaniz, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasini ziyaret edin, giris yapin ve `New Issue` butonuna basin. Lutfen sorununuzu mumkun oldugunca ayrintili yazin, tercihen Ingilizce olarak. Ayrica [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katilabilir ve destek kanalini ziyaret edebilirsiniz, ancak yanit sureleri daha uzun olabilir.
@@ -356,50 +411,6 @@ Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/proj
-## Sponsorlar
-
-
-
-
-
-## Destek
-
-Termix ile ilgili yardima ihtiyaciniz varsa veya bir ozellik talep etmek istiyorsaniz, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasini ziyaret edin, giris yapin ve `New Issue` butonuna basin. Lutfen sorununuzu mumkun oldugunca ayrintili yazin, tercihen Ingilizce olarak. Ayrica [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katilabilir ve destek kanalini ziyaret edebilirsiniz, ancak yanit sureleri daha uzun olabilir.
-
-
-
## Lisans
Apache Lisansi Surumu 2.0 altinda dagitilmaktadir. Daha fazla bilgi icin `LICENSE` dosyasina bakin.
diff --git a/readme/README-VI.md b/docs/readme/README-VI.md
similarity index 83%
rename from readme/README-VI.md
rename to docs/readme/README-VI.md
index c0038d8c..106b48c5 100644
--- a/readme/README-VI.md
+++ b/docs/readme/README-VI.md
@@ -31,12 +31,14 @@
+
+
+
+
Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển.
-
-
@@ -56,7 +58,7 @@ Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu
## Tong Quan
-Termix la nen tang quan ly may chu tat ca trong mot, ma nguon mo, mien phi vinh vien, tu luu tru. No cung cap giai phap da nen tang de quan ly may chu va co so ha tang cua ban thong qua mot giao dien truc quan duy nhat. Termix cung cap quyen truy cap terminal SSH, dieu khien may tinh tu xa (RDP, VNC, Telnet), kha nang tao duong ham SSH, quan ly tep SSH tu xa va nhieu cong cu khac. Termix la giai phap thay the mien phi va tu luu tru hoan hao cho Termius, kha dung tren tat ca cac nen tang.
+Termix la nen tang quan ly may chu tat ca trong mot, ma nguon mo, mien phi vinh vien, tu luu tru. No cung cap giai phap da nen tang de quan ly may chu va co so ha tang cua ban thong qua mot giao dien truc quan duy nhat. Termix cung cap quyen truy cap terminal SSH, dieu khien may tinh tu xa (RDP, VNC, Telnet), kha nang tao duong ham SSH, quan ly tep tu xa va nhieu cong cu khac. Termix la giai phap thay the mien phi va tu luu tru hoan hao cho Termius, kha dung tren tat ca cac nen tang.
@@ -81,13 +83,13 @@ Ho tro RDP, VNC va Telnet qua trinh duyet voi day du tuy chinh va chia man hinh.
|
**Quan Ly Duong Ham SSH:**
-Tao va quan ly duong ham SSH giua cac may chu voi tu dong ket noi lai, giam sat suc khoe va chuyen tiep cuc bo, tu xa hoac SOCKS dong. Cai dat duong ham tu may khach desktop den may chu duoc luu tru cuc bo cho moi ban cai dat desktop; cac snapshot C2S preset tuy chon co the duoc luu tren may chu, doi ten, tai hoac xoa de di chuyen cau hinh duong ham cuc bo giua cac may khach.
+Tao va quan ly duong ham SSH giua cac may chu voi tu dong ket noi lai, giam sat suc khoe va chuyen tiep cuc bo, tu xa hoac SOCKS dong. Cai dat duong ham tu may khach desktop den may chu duoc luu tru cuc bo cho moi ban cai dat desktop; cac snapshot C2S preset tuy chon co the duoc luu tren may chu, doi ten, tai hoac xoa khi ban muon di chuyen mot cau hinh duong ham cuc bo giua cac may khach.
|
**Trinh Quan Ly Tep Tu Xa:**
-Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh anh, am thanh va video. Tai len, tai xuong, doi ten, xoa va di chuyen tep lien mach voi ho tro sudo.
+Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh anh, am thanh va video. Tai len, tai xuong, doi ten, xoa va di chuyen tep lien mach voi ho tro sudo. Bao gom ho tro di chuyen tep tu may chu nay sang may chu khac.
|
@@ -109,13 +111,13 @@ Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc (ho tro tuy c
**Chi So May Chu:**
-Xem muc su dung CPU, bo nho, o dia, mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong, trinh xem nhat ky, nguoi dung/quyen, chung chi va nhieu hon nua tren hau het cac may chu chay Linux.
+Xem muc su dung CPU, bo nho, o dia, mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong, trinh xem nhat ky, nguoi dung/quyen, chung chi va nhieu hon nua tren hau het cac may chu chay Linux. Bao gom bieu do lich su theo chuoi thoi gian va canh bao dua tren nguong voi ho tro ntfy va webhook.
|
**Xac Thuc Nguoi Dung:**
-Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC/LDAP/SSO (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau. Xem nhat ky kiem toan cac hanh dong cua tat ca nguoi dung.
+Quan ly nguoi dung an toan voi quyen quan tri (co the chinh sua thong tin cua nguoi dung khac) va ho tro OIDC/LDAP/SSO (co kiem soat truy cap), 2FA (TOTP) va passkey (WebAuthn). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau. Xem nhat ky kiem toan cac hanh dong cua tat ca nguoi dung.
|
@@ -128,8 +130,8 @@ Liet ke cac thiet bi trong mang Tailscale de nhanh chong them vao lam may chu, v
-**RBAC:**
-Tao vai tro va chia se may chu giua nguoi dung/vai tro.
+**RBAC/Chia Se:**
+Tao vai tro va chia se may chu giua nguoi dung/vai tro. Ho tro tat ca cac loai xac thuc va tat ca cac giao thuc may chu.
|
@@ -143,7 +145,7 @@ Ket noi voi cac thiet bi noi tiep (router, switch, vi dieu khien, v.v.) truc tie
**Canh Bao:**
-Dat cac quy tac canh bao dua tren nguong cho chi so may chu (CPU, bo nho, o dia, v.v.) va nhan thong bao qua ntfy hoac webhook khi chung khi toa. Xem canh bao dang kich hoat va da giai quyet trong nhat ky lich su.
+Dat cac quy tac canh bao dua tren nguong cho chi so may chu (CPU, bo nho, o dia, v.v.) va nhan thong bao qua ntfy hoac webhook khi chung kich hoat. Xem canh bao dang kich hoat va da giai quyet trong nhat ky lich su.
|
@@ -206,7 +208,8 @@ Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.term
- **Ket Noi Nhanh** - Ket noi den may chu ma khong can luu du lieu ket noi
- **Bang Lenh** - Nhan dup phim shift trai de truy cap nhanh cac ket noi SSH bang ban phim
- **Tich Hop Proxmox** - Tu dong them may chu vao Termix tu instance Proxmox cua ban
-- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ghi nhat ky terminal, v.v.
+- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ghi nhat ky terminal, chuyen tiep SSH agent, Bitwarden SSH agent, ky SSH bang HashiCorp Vault va nhieu hon nua.
+- **Termix ID** - Mot tuong duong cua sshid.io duoc tich hop san trong Termix. Dang ky mot ten dinh danh, cong bo khoa SSH cong khai cua ban tai mot URL phan giai va su dung CA tich hop san de cap chung chi SSH.
@@ -249,7 +252,9 @@ Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.term
## Cai Dat
-Truy cap [Tai Lieu](https://docs.termix.site/install) Termix de biet them thong tin ve cach cai dat Termix tren tat ca cac nen tang. Ngoai ra, xem tep Docker Compose mau tai day (ban co the bo qua guacd va mang neu khong co y dinh su dung cac tinh nang dieu khien may tinh tu xa):
+Truy cap [Tai Lieu](https://docs.termix.site/install) Termix de biet them thong tin ve cach cai dat Termix tren tat ca cac nen tang.
+
+Tep Docker Compose mau (ban co the bo qua `guacd` va mang neu khong co y dinh su dung cac tinh nang dieu khien may tinh tu xa):
```yaml
services:
@@ -290,9 +295,59 @@ networks:
## Quyên góp
-Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển.
+Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký hay trả phí. Nếu bạn thấy hữu ích, hãy cân nhắc quyên góp để giúp trang trải chi phí máy chủ, tên miền và thời gian phát triển. Các khoản quyên góp cũng giúp tài trợ thời gian nghiên cứu và tìm hiểu những gì cần thiết để xây dựng các tính năng như SAML, Kubernetes và hỗ trợ Agent. Theo dõi tiến độ và quyên góp bên dưới.
-
+[Quyên góp](https://donate.termix.site/)
+
+
+
+## Nha Tai Tro
+
+Ban quan tam den viec dat quang cao tra phi de ho tro phat trien? Gui email toi [mail@termix.site](mailto:mail@termix.site).
+
+
+
+
+
+## Ho Tro
+
+Neu ban can tro giup hoac muon yeu cau tinh nang voi Termix, hay truy cap trang [Van De](https://github.com/Termix-SSH/Support/issues), dang nhap va nhan `New Issue`. Vui long mo ta van de cang chi tiet cang tot, uu tien viet bang tieng Anh. Ban cung co the tham gia may chu [Discord](https://discord.gg/jVQGdvHDrf) va truy cap kenh ho tro, tuy nhien thoi gian phan hoi co the lau hon.
@@ -356,50 +411,6 @@ Xem [Du An](https://github.com/orgs/Termix-SSH/projects/5) de biet tat ca cac ti
-## Nha Tai Tro
-
-
-
-
-
-## Ho Tro
-
-Neu ban can tro giup hoac muon yeu cau tinh nang voi Termix, hay truy cap trang [Van De](https://github.com/Termix-SSH/Support/issues), dang nhap va nhan `New Issue`. Vui long mo ta van de cang chi tiet cang tot, uu tien viet bang tieng Anh. Ban cung co the tham gia may chu [Discord](https://discord.gg/jVQGdvHDrf) va truy cap kenh ho tro, tuy nhien thoi gian phan hoi co the lau hon.
-
-
-
## Giay Phep
Duoc phan phoi theo Giay Phep Apache Phien Ban 2.0. Xem `LICENSE` de biet them thong tin.
diff --git a/repo-images/Image 1.png b/docs/repo-images/Image 1.png
similarity index 100%
rename from repo-images/Image 1.png
rename to docs/repo-images/Image 1.png
diff --git a/repo-images/Image 10.png b/docs/repo-images/Image 10.png
similarity index 100%
rename from repo-images/Image 10.png
rename to docs/repo-images/Image 10.png
diff --git a/repo-images/Image 11.png b/docs/repo-images/Image 11.png
similarity index 100%
rename from repo-images/Image 11.png
rename to docs/repo-images/Image 11.png
diff --git a/repo-images/Image 12.png b/docs/repo-images/Image 12.png
similarity index 100%
rename from repo-images/Image 12.png
rename to docs/repo-images/Image 12.png
diff --git a/repo-images/Image 13.png b/docs/repo-images/Image 13.png
similarity index 100%
rename from repo-images/Image 13.png
rename to docs/repo-images/Image 13.png
diff --git a/repo-images/Image 14.png b/docs/repo-images/Image 14.png
similarity index 100%
rename from repo-images/Image 14.png
rename to docs/repo-images/Image 14.png
diff --git a/repo-images/Image 15.png b/docs/repo-images/Image 15.png
similarity index 100%
rename from repo-images/Image 15.png
rename to docs/repo-images/Image 15.png
diff --git a/repo-images/Image 16.png b/docs/repo-images/Image 16.png
similarity index 100%
rename from repo-images/Image 16.png
rename to docs/repo-images/Image 16.png
diff --git a/repo-images/Image 2.png b/docs/repo-images/Image 2.png
similarity index 100%
rename from repo-images/Image 2.png
rename to docs/repo-images/Image 2.png
diff --git a/repo-images/Image 3.png b/docs/repo-images/Image 3.png
similarity index 100%
rename from repo-images/Image 3.png
rename to docs/repo-images/Image 3.png
diff --git a/repo-images/Image 4.png b/docs/repo-images/Image 4.png
similarity index 100%
rename from repo-images/Image 4.png
rename to docs/repo-images/Image 4.png
diff --git a/repo-images/Image 5.png b/docs/repo-images/Image 5.png
similarity index 100%
rename from repo-images/Image 5.png
rename to docs/repo-images/Image 5.png
diff --git a/repo-images/Image 6.png b/docs/repo-images/Image 6.png
similarity index 100%
rename from repo-images/Image 6.png
rename to docs/repo-images/Image 6.png
diff --git a/repo-images/Image 7.png b/docs/repo-images/Image 7.png
similarity index 100%
rename from repo-images/Image 7.png
rename to docs/repo-images/Image 7.png
diff --git a/repo-images/Image 8.png b/docs/repo-images/Image 8.png
similarity index 100%
rename from repo-images/Image 8.png
rename to docs/repo-images/Image 8.png
diff --git a/repo-images/Image 9.png b/docs/repo-images/Image 9.png
similarity index 100%
rename from repo-images/Image 9.png
rename to docs/repo-images/Image 9.png
diff --git a/repo-images/Repo of the Day.png b/docs/repo-images/Repo of the Day.png
similarity index 100%
rename from repo-images/Repo of the Day.png
rename to docs/repo-images/Repo of the Day.png
diff --git a/repo-images/Termix Header.png b/docs/repo-images/Termix Header.png
similarity index 100%
rename from repo-images/Termix Header.png
rename to docs/repo-images/Termix Header.png
diff --git a/repo-images/YouTube.png b/docs/repo-images/YouTube.png
similarity index 100%
rename from repo-images/YouTube.png
rename to docs/repo-images/YouTube.png
diff --git a/electron-builder.json b/electron-builder.json
index 81106b0b..1386153c 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -117,8 +117,8 @@
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"gatekeeperAssess": false,
- "entitlements": "build/entitlements.mac.plist",
- "entitlementsInherit": "build/entitlements.mac.inherit.plist",
+ "entitlements": "packaging/build/entitlements.mac.plist",
+ "entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution",
"minimumSystemVersion": "10.15",
"mergeASARs": false,
@@ -129,12 +129,12 @@
"artifactName": "termix_macos_${arch}_dmg.${ext}",
"sign": true
},
- "afterPack": "build/after-pack.cjs",
- "afterSign": "build/notarize.cjs",
+ "afterPack": "packaging/build/after-pack.cjs",
+ "afterSign": "packaging/build/notarize.cjs",
"mas": {
- "provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
- "entitlements": "build/entitlements.mas.plist",
- "entitlementsInherit": "build/entitlements.mas.inherit.plist",
+ "provisioningProfile": "packaging/build/Termix_Mac_App_Store.provisionprofile",
+ "entitlements": "packaging/build/entitlements.mas.plist",
+ "entitlementsInherit": "packaging/build/entitlements.mas.inherit.plist",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"type": "distribution",
diff --git a/electron/main.cjs b/electron/main.cjs
index 2efe350f..13963207 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -1397,7 +1397,7 @@ ipcMain.handle(
server.once("error", fail);
- server.listen(callbackPort, "127.0.0.1", async () => {
+ server.listen(callbackPort, "localhost", async () => {
try {
await shell.openExternal(authUrl);
} catch (error) {
diff --git a/package-lock.json b/package-lock.json
index 06505929..4e9b87b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,19 @@
{
"name": "termix",
- "version": "2.5.0",
+ "version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
- "version": "2.5.0",
+ "version": "2.5.1",
"hasInstallScript": true,
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
+ "@tanstack/react-virtual": "^3.14.6",
"@types/ldapjs": "^3.0.6",
- "axios": "^1.18.0",
+ "axios": "^1.18.1",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"body-parser": "^2.3.0",
@@ -24,28 +25,28 @@
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jose": "^6.2.2",
- "js-yaml": "^5.0.0",
+ "js-yaml": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
- "motion": "^12.38.0",
+ "motion": "^12.42.2",
"multer": "^2.2.0",
- "nanoid": "^5.1.15",
+ "nanoid": "^5.1.16",
"qrcode": "^1.5.4",
"serialport": "^13.0.0",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
- "undici": "^8.5.0",
+ "undici": "^8.7.0",
"ws": "^8.20.0"
},
"devDependencies": {
- "@biomejs/biome": "2.5.1",
+ "@biomejs/biome": "2.5.2",
"@codemirror/autocomplete": "^6.20.3",
- "@codemirror/commands": "^6.10.3",
+ "@codemirror/commands": "^6.10.4",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.43.1",
+ "@codemirror/view": "^6.43.5",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -57,23 +58,23 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"@monaco-editor/react": "^4.7.0",
- "@radix-ui/react-accordion": "^1.2.13",
- "@radix-ui/react-alert-dialog": "^1.1.16",
- "@radix-ui/react-checkbox": "^1.3.4",
- "@radix-ui/react-dialog": "^1.1.16",
- "@radix-ui/react-dropdown-menu": "^2.1.17",
- "@radix-ui/react-label": "^2.1.9",
- "@radix-ui/react-popover": "^1.1.16",
- "@radix-ui/react-progress": "^1.1.9",
- "@radix-ui/react-scroll-area": "^1.2.11",
- "@radix-ui/react-select": "^2.3.1",
- "@radix-ui/react-separator": "^1.1.9",
- "@radix-ui/react-slider": "^1.4.1",
+ "@radix-ui/react-accordion": "^1.2.15",
+ "@radix-ui/react-alert-dialog": "^1.1.18",
+ "@radix-ui/react-checkbox": "^1.3.6",
+ "@radix-ui/react-dialog": "^1.1.18",
+ "@radix-ui/react-dropdown-menu": "^2.1.19",
+ "@radix-ui/react-label": "^2.1.11",
+ "@radix-ui/react-popover": "^1.1.18",
+ "@radix-ui/react-progress": "^1.1.11",
+ "@radix-ui/react-scroll-area": "^1.2.13",
+ "@radix-ui/react-select": "^2.3.2",
+ "@radix-ui/react-separator": "^1.1.11",
+ "@radix-ui/react-slider": "^1.4.2",
"@radix-ui/react-slot": "^1.3.0",
- "@radix-ui/react-switch": "^1.3.1",
- "@radix-ui/react-tabs": "^1.1.14",
- "@radix-ui/react-tooltip": "^1.2.9",
- "@tailwindcss/vite": "^4.3.1",
+ "@radix-ui/react-switch": "^1.3.2",
+ "@radix-ui/react-tabs": "^1.1.16",
+ "@radix-ui/react-tooltip": "^1.2.11",
+ "@tailwindcss/vite": "^4.3.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -96,7 +97,7 @@
"@uiw/codemirror-extensions-langs": "^4.25.9",
"@uiw/codemirror-theme-github": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
- "@vitejs/plugin-react": "^6.0.1",
+ "@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"@xterm/addon-clipboard": "^0.2.0",
@@ -109,7 +110,7 @@
"cmdk": "^1.1.1",
"concurrently": "^10.0.3",
"cytoscape": "^3.34.0",
- "electron": "^42.4.1",
+ "electron": "^43.0.0",
"electron-builder": "^26.15.3",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -118,13 +119,13 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
- "i18next": "^26.3.1",
+ "i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"lucide-react": "^1.20.0",
"prettier": "3.8.4",
- "radix-ui": "^1.6.0",
+ "radix-ui": "^1.6.1",
"react": "^19.2.7",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.7",
@@ -138,7 +139,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"remark-gfm": "^4.0.1",
- "sharp": "^0.35.2",
+ "sharp": "^0.35.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
@@ -568,9 +569,9 @@
}
},
"node_modules/@biomejs/biome": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.1.tgz",
- "integrity": "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.2.tgz",
+ "integrity": "sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -584,20 +585,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
- "@biomejs/cli-darwin-arm64": "2.5.1",
- "@biomejs/cli-darwin-x64": "2.5.1",
- "@biomejs/cli-linux-arm64": "2.5.1",
- "@biomejs/cli-linux-arm64-musl": "2.5.1",
- "@biomejs/cli-linux-x64": "2.5.1",
- "@biomejs/cli-linux-x64-musl": "2.5.1",
- "@biomejs/cli-win32-arm64": "2.5.1",
- "@biomejs/cli-win32-x64": "2.5.1"
+ "@biomejs/cli-darwin-arm64": "2.5.2",
+ "@biomejs/cli-darwin-x64": "2.5.2",
+ "@biomejs/cli-linux-arm64": "2.5.2",
+ "@biomejs/cli-linux-arm64-musl": "2.5.2",
+ "@biomejs/cli-linux-x64": "2.5.2",
+ "@biomejs/cli-linux-x64-musl": "2.5.2",
+ "@biomejs/cli-win32-arm64": "2.5.2",
+ "@biomejs/cli-win32-x64": "2.5.2"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.1.tgz",
- "integrity": "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.2.tgz",
+ "integrity": "sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==",
"cpu": [
"arm64"
],
@@ -612,9 +613,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.1.tgz",
- "integrity": "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.2.tgz",
+ "integrity": "sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==",
"cpu": [
"x64"
],
@@ -629,16 +630,13 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.1.tgz",
- "integrity": "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.2.tgz",
+ "integrity": "sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -649,16 +647,13 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.1.tgz",
- "integrity": "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.2.tgz",
+ "integrity": "sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -669,16 +664,13 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.1.tgz",
- "integrity": "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.2.tgz",
+ "integrity": "sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -689,16 +681,13 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.1.tgz",
- "integrity": "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.2.tgz",
+ "integrity": "sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -709,9 +698,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.1.tgz",
- "integrity": "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.2.tgz",
+ "integrity": "sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==",
"cpu": [
"arm64"
],
@@ -726,9 +715,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.1.tgz",
- "integrity": "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg==",
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.2.tgz",
+ "integrity": "sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==",
"cpu": [
"x64"
],
@@ -769,14 +758,14 @@
}
},
"node_modules/@codemirror/commands": {
- "version": "6.10.3",
- "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
- "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.6.0",
+ "@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
@@ -1117,9 +1106,9 @@
}
},
"node_modules/@codemirror/state": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
- "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1140,13 +1129,13 @@
}
},
"node_modules/@codemirror/view": {
- "version": "6.43.1",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
- "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
+ "version": "6.43.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz",
+ "integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@codemirror/state": "^6.6.0",
+ "@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
@@ -2341,9 +2330,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
- "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -2360,13 +2349,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.3.1"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
- "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -2383,13 +2372,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.3.1"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
- "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
@@ -2397,7 +2386,7 @@
"freebsd"
],
"dependencies": {
- "@img/sharp-wasm32": "0.35.2"
+ "@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
@@ -2407,9 +2396,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
- "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -2424,9 +2413,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
- "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -2441,16 +2430,13 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
- "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2461,16 +2447,13 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
- "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2481,16 +2464,13 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
- "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2501,16 +2481,13 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
- "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2521,16 +2498,13 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
- "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2541,16 +2515,13 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
- "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2561,16 +2532,13 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
- "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2581,16 +2549,13 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
- "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2601,16 +2566,13 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
- "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2623,20 +2585,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.3.1"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
- "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2649,20 +2608,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.3.1"
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
- "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2675,20 +2631,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.3.1"
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
- "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2701,20 +2654,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.3.1"
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
- "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2727,20 +2677,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.3.1"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
- "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2753,20 +2700,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.3.1"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
- "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2779,20 +2723,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
- "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2805,13 +2746,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.3.1"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
- "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
@@ -2826,9 +2767,9 @@
}
},
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2837,9 +2778,9 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
- "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
@@ -2847,7 +2788,7 @@
"license": "Apache-2.0",
"optional": true,
"dependencies": {
- "@img/sharp-wasm32": "0.35.2"
+ "@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
@@ -2857,9 +2798,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
- "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@@ -2877,9 +2818,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
- "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -2897,9 +2838,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
- "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -3895,13 +3836,13 @@
"license": "MIT"
},
"node_modules/@radix-ui/react-accessible-icon": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.10.tgz",
- "integrity": "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz",
+ "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-visually-hidden": "1.2.6"
+ "@radix-ui/react-visually-hidden": "1.2.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -3919,20 +3860,20 @@
}
},
"node_modules/@radix-ui/react-accordion": {
- "version": "1.2.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz",
- "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==",
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.15.tgz",
+ "integrity": "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collapsible": "1.1.14",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collapsible": "1.1.15",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -3951,17 +3892,17 @@
}
},
"node_modules/@radix-ui/react-alert-dialog": {
- "version": "1.1.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz",
- "integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.18.tgz",
+ "integrity": "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dialog": "1.1.17",
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-dialog": "1.1.18",
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -3979,13 +3920,13 @@
}
},
"node_modules/@radix-ui/react-arrow": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz",
- "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
+ "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4003,13 +3944,13 @@
}
},
"node_modules/@radix-ui/react-aspect-ratio": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.10.tgz",
- "integrity": "sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz",
+ "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4027,14 +3968,14 @@
}
},
"node_modules/@radix-ui/react-avatar": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.0.tgz",
- "integrity": "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.1.tgz",
+ "integrity": "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-is-hydrated": "0.1.1",
"@radix-ui/react-use-layout-effect": "1.1.2"
@@ -4055,9 +3996,9 @@
}
},
"node_modules/@radix-ui/react-checkbox": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz",
- "integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==",
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.6.tgz",
+ "integrity": "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4065,7 +4006,7 @@
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "1.1.2"
@@ -4086,9 +4027,9 @@
}
},
"node_modules/@radix-ui/react-collapsible": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz",
- "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==",
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.15.tgz",
+ "integrity": "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4097,7 +4038,7 @@
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
@@ -4117,15 +4058,15 @@
}
},
"node_modules/@radix-ui/react-collection": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz",
- "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz",
+ "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
@@ -4176,16 +4117,16 @@
}
},
"node_modules/@radix-ui/react-context-menu": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.1.tgz",
- "integrity": "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.2.tgz",
+ "integrity": "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-menu": "2.1.18",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -4204,22 +4145,22 @@
}
},
"node_modules/@radix-ui/react-dialog": {
- "version": "1.1.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz",
- "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz",
+ "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.13",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.10",
+ "@radix-ui/react-focus-scope": "1.1.11",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
@@ -4257,17 +4198,17 @@
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz",
- "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz",
+ "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
- "@radix-ui/react-use-escape-keydown": "1.1.2"
+ "@radix-ui/react-use-effect-event": "0.0.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -4285,9 +4226,9 @@
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
- "version": "2.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz",
- "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==",
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.19.tgz",
+ "integrity": "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4295,8 +4236,8 @@
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-menu": "2.1.18",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -4331,14 +4272,14 @@
}
},
"node_modules/@radix-ui/react-focus-scope": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz",
- "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz",
+ "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
@@ -4357,9 +4298,9 @@
}
},
"node_modules/@radix-ui/react-form": {
- "version": "0.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.10.tgz",
- "integrity": "sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==",
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.11.tgz",
+ "integrity": "sha512-0mTMJHv1gQAuEQoq5VDpTD3MRgmfUFdXAVFhpqR7wBeUr+tyRsof0wv/4XdPHLwQrefhoH2FiGHCggrCJhalIw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4367,8 +4308,8 @@
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-label": "2.1.10",
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-label": "2.1.11",
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4386,20 +4327,20 @@
}
},
"node_modules/@radix-ui/react-hover-card": {
- "version": "1.1.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.17.tgz",
- "integrity": "sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.18.tgz",
+ "integrity": "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.13",
- "@radix-ui/react-popper": "1.3.1",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -4437,13 +4378,13 @@
}
},
"node_modules/@radix-ui/react-label": {
- "version": "2.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz",
- "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==",
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz",
+ "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4461,26 +4402,26 @@
}
},
"node_modules/@radix-ui/react-menu": {
- "version": "2.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz",
- "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==",
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.19.tgz",
+ "integrity": "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.13",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.10",
+ "@radix-ui/react-focus-scope": "1.1.11",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.1",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"aria-hidden": "^1.2.4",
@@ -4502,21 +4443,21 @@
}
},
"node_modules/@radix-ui/react-menubar": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.18.tgz",
- "integrity": "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==",
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.19.tgz",
+ "integrity": "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-menu": "2.1.18",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
+ "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -4535,26 +4476,26 @@
}
},
"node_modules/@radix-ui/react-navigation-menu": {
- "version": "1.2.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz",
- "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==",
+ "version": "1.2.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.17.tgz",
+ "integrity": "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.13",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.6"
+ "@radix-ui/react-visually-hidden": "1.2.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4572,20 +4513,20 @@
}
},
"node_modules/@radix-ui/react-one-time-password-field": {
- "version": "0.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.10.tgz",
- "integrity": "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==",
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.11.tgz",
+ "integrity": "sha512-Rsgab65u73E5kPVh8OS6PgPwJgPyf08GFfJDGAbMdF4DL7CgDhFOaDnXuk/DiMEVF6kgQwl0oJmFklvipmiOLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-is-hydrated": "0.1.1",
@@ -4607,9 +4548,9 @@
}
},
"node_modules/@radix-ui/react-password-toggle-field": {
- "version": "0.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.5.tgz",
- "integrity": "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==",
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.6.tgz",
+ "integrity": "sha512-pQ3xGp/uemomASPH97Eb3shfXX8QlG11bBJyEvRBV+vwtO4HvQlS06Yj9f31Ao7XepvF98SFrRgVDQ7jv+2xjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4617,7 +4558,7 @@
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-is-hydrated": "0.1.1"
@@ -4638,23 +4579,23 @@
}
},
"node_modules/@radix-ui/react-popover": {
- "version": "1.1.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz",
- "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.18.tgz",
+ "integrity": "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.13",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.10",
+ "@radix-ui/react-focus-scope": "1.1.11",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.1",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
@@ -4676,17 +4617,17 @@
}
},
"node_modules/@radix-ui/react-popper": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
- "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
+ "integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
- "@radix-ui/react-arrow": "1.1.10",
+ "@radix-ui/react-arrow": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
@@ -4709,13 +4650,13 @@
}
},
"node_modules/@radix-ui/react-portal": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz",
- "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
+ "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
@@ -4758,9 +4699,9 @@
}
},
"node_modules/@radix-ui/react-primitive": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
- "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz",
+ "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4782,14 +4723,14 @@
}
},
"node_modules/@radix-ui/react-progress": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.10.tgz",
- "integrity": "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.11.tgz",
+ "integrity": "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4807,9 +4748,9 @@
}
},
"node_modules/@radix-ui/react-radio-group": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.1.tgz",
- "integrity": "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.2.tgz",
+ "integrity": "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4818,8 +4759,8 @@
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "1.1.2"
@@ -4840,19 +4781,19 @@
}
},
"node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz",
- "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz",
+ "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
@@ -4872,9 +4813,9 @@
}
},
"node_modules/@radix-ui/react-scroll-area": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz",
- "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.13.tgz",
+ "integrity": "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4884,7 +4825,7 @@
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
@@ -4904,32 +4845,32 @@
}
},
"node_modules/@radix-ui/react-select": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz",
- "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.2.tgz",
+ "integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.13",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.10",
+ "@radix-ui/react-focus-scope": "1.1.11",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.1",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.6",
+ "@radix-ui/react-visually-hidden": "1.2.7",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
@@ -4949,13 +4890,13 @@
}
},
"node_modules/@radix-ui/react-separator": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz",
- "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz",
+ "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -4973,19 +4914,19 @@
}
},
"node_modules/@radix-ui/react-slider": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.1.tgz",
- "integrity": "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.2.tgz",
+ "integrity": "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
@@ -5026,16 +4967,16 @@
}
},
"node_modules/@radix-ui/react-switch": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.1.tgz",
- "integrity": "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.2.tgz",
+ "integrity": "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "1.1.2"
@@ -5056,9 +4997,9 @@
}
},
"node_modules/@radix-ui/react-tabs": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz",
- "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz",
+ "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5067,8 +5008,8 @@
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -5087,24 +5028,24 @@
}
},
"node_modules/@radix-ui/react-toast": {
- "version": "1.2.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.17.tgz",
- "integrity": "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==",
+ "version": "1.2.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.18.tgz",
+ "integrity": "sha512-YNEnTHV47hPep+U0QvVM02OJNka9uygREc+k4Nh5VSZBg4MmE+myI442x3hCGfRpX7N2WSSYSJKws4gE+Z8lgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.13",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-layout-effect": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.6"
+ "@radix-ui/react-visually-hidden": "1.2.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -5122,14 +5063,14 @@
}
},
"node_modules/@radix-ui/react-toggle": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.12.tgz",
- "integrity": "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.13.tgz",
+ "integrity": "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -5148,18 +5089,18 @@
}
},
"node_modules/@radix-ui/react-toggle-group": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.13.tgz",
- "integrity": "sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.14.tgz",
+ "integrity": "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
- "@radix-ui/react-toggle": "1.1.12",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
+ "@radix-ui/react-toggle": "1.1.13",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
@@ -5178,19 +5119,19 @@
}
},
"node_modules/@radix-ui/react-toolbar": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.13.tgz",
- "integrity": "sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.14.tgz",
+ "integrity": "sha512-L/EkWVqlnj3lL2toHh4C7PwH2jxfa7OCq6lGfXSCii99ve2S4Ux5rc9HnOa7LN9exHa/Nl9kmCAmP9BuDPy5UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-roving-focus": "1.1.13",
- "@radix-ui/react-separator": "1.1.10",
- "@radix-ui/react-toggle-group": "1.1.13"
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
+ "@radix-ui/react-separator": "1.1.11",
+ "@radix-ui/react-toggle-group": "1.1.14"
},
"peerDependencies": {
"@types/react": "*",
@@ -5208,24 +5149,24 @@
}
},
"node_modules/@radix-ui/react-tooltip": {
- "version": "1.2.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz",
- "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==",
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.11.tgz",
+ "integrity": "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.13",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.1",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
- "@radix-ui/react-visually-hidden": "1.2.6"
+ "@radix-ui/react-visually-hidden": "1.2.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -5298,9 +5239,9 @@
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
- "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz",
+ "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5403,13 +5344,13 @@
}
},
"node_modules/@radix-ui/react-visually-hidden": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz",
- "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==",
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz",
+ "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.6"
+ "@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -6345,9 +6286,9 @@
}
},
"node_modules/@tailwindcss/node": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
- "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
+ "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6357,37 +6298,37 @@
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.3.1"
+ "tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
- "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
+ "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.3.1",
- "@tailwindcss/oxide-darwin-arm64": "4.3.1",
- "@tailwindcss/oxide-darwin-x64": "4.3.1",
- "@tailwindcss/oxide-freebsd-x64": "4.3.1",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
- "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
- "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
+ "@tailwindcss/oxide-android-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-x64": "4.3.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
- "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
+ "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
"cpu": [
"arm64"
],
@@ -6402,9 +6343,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
- "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
+ "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
"cpu": [
"arm64"
],
@@ -6419,9 +6360,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
- "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
+ "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
"cpu": [
"x64"
],
@@ -6436,9 +6377,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
- "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
+ "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
"cpu": [
"x64"
],
@@ -6453,9 +6394,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
- "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
+ "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
"cpu": [
"arm"
],
@@ -6470,16 +6411,13 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
- "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
+ "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6490,16 +6428,13 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
- "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
+ "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
"cpu": [
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6510,16 +6445,13 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
- "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
+ "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6530,16 +6462,13 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
- "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
+ "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
"cpu": [
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6550,9 +6479,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
- "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
+ "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -6568,9 +6497,9 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.10.0",
- "@emnapi/runtime": "^1.10.0",
- "@emnapi/wasi-threads": "^1.2.1",
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
@@ -6580,18 +6509,18 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
- "version": "1.10.0",
+ "version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
+ "@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
- "version": "1.10.0",
+ "version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -6601,7 +6530,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
+ "version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -6646,9 +6575,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
- "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
+ "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
"cpu": [
"arm64"
],
@@ -6663,9 +6592,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
- "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
+ "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
"cpu": [
"x64"
],
@@ -6680,20 +6609,47 @@
}
},
"node_modules/@tailwindcss/vite": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz",
- "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
+ "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@tailwindcss/node": "4.3.1",
- "@tailwindcss/oxide": "4.3.1",
- "tailwindcss": "4.3.1"
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "tailwindcss": "4.3.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.14.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.6.tgz",
+ "integrity": "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.17.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.17.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz",
+ "integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -7536,13 +7492,13 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
- "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@rolldown/pluginutils": "^1.0.0"
+ "@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -8259,9 +8215,9 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.18.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
- "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
+ "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -10083,9 +10039,9 @@
}
},
"node_modules/electron": {
- "version": "42.4.1",
- "resolved": "https://registry.npmjs.org/electron/-/electron-42.4.1.tgz",
- "integrity": "sha512-8CYHJP5O4wFO+ycoJR98yy907MmPeo+vWXrzjxmGGgRNKqv8pOjjm+wphO0CCgQJnBU7+QUPSJS4QXhbKrO50w==",
+ "version": "43.0.0",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-43.0.0.tgz",
+ "integrity": "sha512-PV60GsWU6qufhuOhw3n+Yix3WPDcqDtBqE8orbEQGQGHEkgp9o/JCPgb7L4vIL0r1HnfPdqSRtboOTqbDkcFDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11068,12 +11024,12 @@
}
},
"node_modules/framer-motion": {
- "version": "12.40.0",
- "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
- "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+ "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
"license": "MIT",
"dependencies": {
- "motion-dom": "^12.40.0",
+ "motion-dom": "^12.42.2",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
@@ -11645,9 +11601,9 @@
}
},
"node_modules/i18next": {
- "version": "26.3.1",
- "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
- "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
+ "version": "26.3.4",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
+ "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"dev": true,
"funding": [
{
@@ -12072,9 +12028,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz",
- "integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
+ "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
"funding": [
{
"type": "github",
@@ -14153,12 +14109,12 @@
"license": "MIT"
},
"node_modules/motion": {
- "version": "12.40.0",
- "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
- "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz",
+ "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
"license": "MIT",
"dependencies": {
- "framer-motion": "^12.40.0",
+ "framer-motion": "^12.42.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -14179,9 +14135,9 @@
}
},
"node_modules/motion-dom": {
- "version": "12.40.0",
- "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
- "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+ "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
@@ -14279,9 +14235,9 @@
"optional": true
},
"node_modules/nanoid": {
- "version": "5.1.15",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
- "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
+ "version": "5.1.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
+ "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
@@ -15382,67 +15338,67 @@
}
},
"node_modules/radix-ui": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.0.tgz",
- "integrity": "sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.1.tgz",
+ "integrity": "sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-accessible-icon": "1.1.10",
- "@radix-ui/react-accordion": "1.2.14",
- "@radix-ui/react-alert-dialog": "1.1.17",
- "@radix-ui/react-arrow": "1.1.10",
- "@radix-ui/react-aspect-ratio": "1.1.10",
- "@radix-ui/react-avatar": "1.2.0",
- "@radix-ui/react-checkbox": "1.3.5",
- "@radix-ui/react-collapsible": "1.1.14",
- "@radix-ui/react-collection": "1.1.10",
+ "@radix-ui/react-accessible-icon": "1.1.11",
+ "@radix-ui/react-accordion": "1.2.15",
+ "@radix-ui/react-alert-dialog": "1.1.18",
+ "@radix-ui/react-arrow": "1.1.11",
+ "@radix-ui/react-aspect-ratio": "1.1.11",
+ "@radix-ui/react-avatar": "1.2.1",
+ "@radix-ui/react-checkbox": "1.3.6",
+ "@radix-ui/react-collapsible": "1.1.15",
+ "@radix-ui/react-collection": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-context-menu": "2.3.1",
- "@radix-ui/react-dialog": "1.1.17",
+ "@radix-ui/react-context-menu": "2.3.2",
+ "@radix-ui/react-dialog": "1.1.18",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.13",
- "@radix-ui/react-dropdown-menu": "2.1.18",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-dropdown-menu": "2.1.19",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.10",
- "@radix-ui/react-form": "0.1.10",
- "@radix-ui/react-hover-card": "1.1.17",
- "@radix-ui/react-label": "2.1.10",
- "@radix-ui/react-menu": "2.1.18",
- "@radix-ui/react-menubar": "1.1.18",
- "@radix-ui/react-navigation-menu": "1.2.16",
- "@radix-ui/react-one-time-password-field": "0.1.10",
- "@radix-ui/react-password-toggle-field": "0.1.5",
- "@radix-ui/react-popover": "1.1.17",
- "@radix-ui/react-popper": "1.3.1",
- "@radix-ui/react-portal": "1.1.12",
+ "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-form": "0.1.11",
+ "@radix-ui/react-hover-card": "1.1.18",
+ "@radix-ui/react-label": "2.1.11",
+ "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-menubar": "1.1.19",
+ "@radix-ui/react-navigation-menu": "1.2.17",
+ "@radix-ui/react-one-time-password-field": "0.1.11",
+ "@radix-ui/react-password-toggle-field": "0.1.6",
+ "@radix-ui/react-popover": "1.1.18",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.6",
- "@radix-ui/react-primitive": "2.1.6",
- "@radix-ui/react-progress": "1.1.10",
- "@radix-ui/react-radio-group": "1.4.1",
- "@radix-ui/react-roving-focus": "1.1.13",
- "@radix-ui/react-scroll-area": "1.2.12",
- "@radix-ui/react-select": "2.3.1",
- "@radix-ui/react-separator": "1.1.10",
- "@radix-ui/react-slider": "1.4.1",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-progress": "1.1.11",
+ "@radix-ui/react-radio-group": "1.4.2",
+ "@radix-ui/react-roving-focus": "1.1.14",
+ "@radix-ui/react-scroll-area": "1.2.13",
+ "@radix-ui/react-select": "2.3.2",
+ "@radix-ui/react-separator": "1.1.11",
+ "@radix-ui/react-slider": "1.4.2",
"@radix-ui/react-slot": "1.3.0",
- "@radix-ui/react-switch": "1.3.1",
- "@radix-ui/react-tabs": "1.1.15",
- "@radix-ui/react-toast": "1.2.17",
- "@radix-ui/react-toggle": "1.1.12",
- "@radix-ui/react-toggle-group": "1.1.13",
- "@radix-ui/react-toolbar": "1.1.13",
- "@radix-ui/react-tooltip": "1.2.10",
+ "@radix-ui/react-switch": "1.3.2",
+ "@radix-ui/react-tabs": "1.1.16",
+ "@radix-ui/react-toast": "1.2.18",
+ "@radix-ui/react-toggle": "1.1.13",
+ "@radix-ui/react-toggle-group": "1.1.14",
+ "@radix-ui/react-toolbar": "1.1.14",
+ "@radix-ui/react-tooltip": "1.2.11",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-effect-event": "0.0.3",
- "@radix-ui/react-use-escape-keydown": "1.1.2",
+ "@radix-ui/react-use-escape-keydown": "1.1.3",
"@radix-ui/react-use-is-hydrated": "0.1.1",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.6"
+ "@radix-ui/react-visually-hidden": "1.2.7"
},
"peerDependencies": {
"@types/react": "*",
@@ -16157,9 +16113,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.8.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
- "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -16277,15 +16233,15 @@
"license": "ISC"
},
"node_modules/sharp": {
- "version": "0.35.2",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
- "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
- "semver": "^7.8.4"
+ "semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
@@ -16294,31 +16250,36 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.35.2",
- "@img/sharp-darwin-x64": "0.35.2",
- "@img/sharp-freebsd-wasm32": "0.35.2",
- "@img/sharp-libvips-darwin-arm64": "1.3.1",
- "@img/sharp-libvips-darwin-x64": "1.3.1",
- "@img/sharp-libvips-linux-arm": "1.3.1",
- "@img/sharp-libvips-linux-arm64": "1.3.1",
- "@img/sharp-libvips-linux-ppc64": "1.3.1",
- "@img/sharp-libvips-linux-riscv64": "1.3.1",
- "@img/sharp-libvips-linux-s390x": "1.3.1",
- "@img/sharp-libvips-linux-x64": "1.3.1",
- "@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
- "@img/sharp-libvips-linuxmusl-x64": "1.3.1",
- "@img/sharp-linux-arm": "0.35.2",
- "@img/sharp-linux-arm64": "0.35.2",
- "@img/sharp-linux-ppc64": "0.35.2",
- "@img/sharp-linux-riscv64": "0.35.2",
- "@img/sharp-linux-s390x": "0.35.2",
- "@img/sharp-linux-x64": "0.35.2",
- "@img/sharp-linuxmusl-arm64": "0.35.2",
- "@img/sharp-linuxmusl-x64": "0.35.2",
- "@img/sharp-webcontainers-wasm32": "0.35.2",
- "@img/sharp-win32-arm64": "0.35.2",
- "@img/sharp-win32-ia32": "0.35.2",
- "@img/sharp-win32-x64": "0.35.2"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/shebang-command": {
@@ -16881,9 +16842,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
- "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
+ "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
"dev": true,
"license": "MIT"
},
@@ -17335,9 +17296,9 @@
}
},
"node_modules/undici": {
- "version": "8.5.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
- "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz",
+ "integrity": "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
diff --git a/package.json b/package.json
index afc647c0..72160103 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
- "version": "2.5.0",
+ "version": "2.5.1",
"description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -14,7 +14,7 @@
"format:check": "prettier --check .",
"biome:check": "biome check biome.json package.json",
"biome:fix": "biome check --write biome.json package.json",
- "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs",
+ "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
@@ -29,7 +29,7 @@
"dev:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/starter.js",
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
- "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/swagger.js",
+ "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
"preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
"electron:patch-builder": "node scripts/patch-app-builder-lib.cjs",
@@ -45,8 +45,9 @@
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
+ "@tanstack/react-virtual": "^3.14.6",
"@types/ldapjs": "^3.0.6",
- "axios": "^1.18.0",
+ "axios": "^1.18.1",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"body-parser": "^2.3.0",
@@ -58,28 +59,28 @@
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jose": "^6.2.2",
- "js-yaml": "^5.0.0",
+ "js-yaml": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
- "motion": "^12.38.0",
+ "motion": "^12.42.2",
"multer": "^2.2.0",
- "nanoid": "^5.1.15",
+ "nanoid": "^5.1.16",
"qrcode": "^1.5.4",
"serialport": "^13.0.0",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
- "undici": "^8.5.0",
+ "undici": "^8.7.0",
"ws": "^8.20.0"
},
"devDependencies": {
- "@biomejs/biome": "2.5.1",
+ "@biomejs/biome": "2.5.2",
"@codemirror/autocomplete": "^6.20.3",
- "@codemirror/commands": "^6.10.3",
+ "@codemirror/commands": "^6.10.4",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.43.1",
+ "@codemirror/view": "^6.43.5",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -91,23 +92,23 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"@monaco-editor/react": "^4.7.0",
- "@radix-ui/react-accordion": "^1.2.13",
- "@radix-ui/react-alert-dialog": "^1.1.16",
- "@radix-ui/react-checkbox": "^1.3.4",
- "@radix-ui/react-dialog": "^1.1.16",
- "@radix-ui/react-dropdown-menu": "^2.1.17",
- "@radix-ui/react-label": "^2.1.9",
- "@radix-ui/react-popover": "^1.1.16",
- "@radix-ui/react-progress": "^1.1.9",
- "@radix-ui/react-scroll-area": "^1.2.11",
- "@radix-ui/react-select": "^2.3.1",
- "@radix-ui/react-separator": "^1.1.9",
- "@radix-ui/react-slider": "^1.4.1",
+ "@radix-ui/react-accordion": "^1.2.15",
+ "@radix-ui/react-alert-dialog": "^1.1.18",
+ "@radix-ui/react-checkbox": "^1.3.6",
+ "@radix-ui/react-dialog": "^1.1.18",
+ "@radix-ui/react-dropdown-menu": "^2.1.19",
+ "@radix-ui/react-label": "^2.1.11",
+ "@radix-ui/react-popover": "^1.1.18",
+ "@radix-ui/react-progress": "^1.1.11",
+ "@radix-ui/react-scroll-area": "^1.2.13",
+ "@radix-ui/react-select": "^2.3.2",
+ "@radix-ui/react-separator": "^1.1.11",
+ "@radix-ui/react-slider": "^1.4.2",
"@radix-ui/react-slot": "^1.3.0",
- "@radix-ui/react-switch": "^1.3.1",
- "@radix-ui/react-tabs": "^1.1.14",
- "@radix-ui/react-tooltip": "^1.2.9",
- "@tailwindcss/vite": "^4.3.1",
+ "@radix-ui/react-switch": "^1.3.2",
+ "@radix-ui/react-tabs": "^1.1.16",
+ "@radix-ui/react-tooltip": "^1.2.11",
+ "@tailwindcss/vite": "^4.3.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -130,7 +131,7 @@
"@uiw/codemirror-extensions-langs": "^4.25.9",
"@uiw/codemirror-theme-github": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
- "@vitejs/plugin-react": "^6.0.1",
+ "@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"@xterm/addon-clipboard": "^0.2.0",
@@ -143,7 +144,7 @@
"cmdk": "^1.1.1",
"concurrently": "^10.0.3",
"cytoscape": "^3.34.0",
- "electron": "^42.4.1",
+ "electron": "^43.0.0",
"electron-builder": "^26.15.3",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -152,13 +153,13 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
- "i18next": "^26.3.1",
+ "i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"lucide-react": "^1.20.0",
"prettier": "3.8.4",
- "radix-ui": "^1.6.0",
+ "radix-ui": "^1.6.1",
"react": "^19.2.7",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.7",
@@ -172,7 +173,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"remark-gfm": "^4.0.1",
- "sharp": "^0.35.2",
+ "sharp": "^0.35.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
diff --git a/Casks/termix.rb b/packaging/Casks/termix.rb
similarity index 100%
rename from Casks/termix.rb
rename to packaging/Casks/termix.rb
diff --git a/build/Termix_Mac_App_Store.provisionprofile b/packaging/build/Termix_Mac_App_Store.provisionprofile
similarity index 100%
rename from build/Termix_Mac_App_Store.provisionprofile
rename to packaging/build/Termix_Mac_App_Store.provisionprofile
diff --git a/build/after-pack.cjs b/packaging/build/after-pack.cjs
similarity index 100%
rename from build/after-pack.cjs
rename to packaging/build/after-pack.cjs
diff --git a/build/entitlements.mac.inherit.plist b/packaging/build/entitlements.mac.inherit.plist
similarity index 100%
rename from build/entitlements.mac.inherit.plist
rename to packaging/build/entitlements.mac.inherit.plist
diff --git a/build/entitlements.mac.plist b/packaging/build/entitlements.mac.plist
similarity index 100%
rename from build/entitlements.mac.plist
rename to packaging/build/entitlements.mac.plist
diff --git a/build/entitlements.mas.inherit.plist b/packaging/build/entitlements.mas.inherit.plist
similarity index 100%
rename from build/entitlements.mas.inherit.plist
rename to packaging/build/entitlements.mas.inherit.plist
diff --git a/build/entitlements.mas.plist b/packaging/build/entitlements.mas.plist
similarity index 100%
rename from build/entitlements.mas.plist
rename to packaging/build/entitlements.mas.plist
diff --git a/build/notarize.cjs b/packaging/build/notarize.cjs
similarity index 100%
rename from build/notarize.cjs
rename to packaging/build/notarize.cjs
diff --git a/chocolatey/termix-ssh.nuspec b/packaging/chocolatey/termix-ssh.nuspec
similarity index 100%
rename from chocolatey/termix-ssh.nuspec
rename to packaging/chocolatey/termix-ssh.nuspec
diff --git a/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1
similarity index 100%
rename from chocolatey/tools/chocolateyinstall.ps1
rename to packaging/chocolatey/tools/chocolateyinstall.ps1
diff --git a/chocolatey/tools/chocolateyuninstall.ps1 b/packaging/chocolatey/tools/chocolateyuninstall.ps1
similarity index 100%
rename from chocolatey/tools/chocolateyuninstall.ps1
rename to packaging/chocolatey/tools/chocolateyuninstall.ps1
diff --git a/flatpak/com.karmaa.termix.desktop b/packaging/flatpak/com.karmaa.termix.desktop
similarity index 100%
rename from flatpak/com.karmaa.termix.desktop
rename to packaging/flatpak/com.karmaa.termix.desktop
diff --git a/flatpak/com.karmaa.termix.flatpakref b/packaging/flatpak/com.karmaa.termix.flatpakref
similarity index 100%
rename from flatpak/com.karmaa.termix.flatpakref
rename to packaging/flatpak/com.karmaa.termix.flatpakref
diff --git a/flatpak/com.karmaa.termix.metainfo.xml b/packaging/flatpak/com.karmaa.termix.metainfo.xml
similarity index 100%
rename from flatpak/com.karmaa.termix.metainfo.xml
rename to packaging/flatpak/com.karmaa.termix.metainfo.xml
diff --git a/flatpak/com.karmaa.termix.yml b/packaging/flatpak/com.karmaa.termix.yml
similarity index 100%
rename from flatpak/com.karmaa.termix.yml
rename to packaging/flatpak/com.karmaa.termix.yml
diff --git a/flatpak/flathub.json b/packaging/flatpak/flathub.json
similarity index 100%
rename from flatpak/flathub.json
rename to packaging/flatpak/flathub.json
diff --git a/scripts/generate-release-body.cjs b/scripts/generate-release-body.cjs
index d213015b..a7e425fb 100644
--- a/scripts/generate-release-body.cjs
+++ b/scripts/generate-release-body.cjs
@@ -95,13 +95,20 @@ function main() {
const videoId = youtubeId(youtube);
const embed = [
``,
- `
`,
+ `
`,
``,
].join("\n");
const table = buildTable(version, mobileVersion);
+ const donateAlert = [
+ "> [!TIP]",
+ "> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.",
+ ].join("\n");
+
const body = [
+ donateAlert,
+ "",
summary,
"",
embed,
diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs
index e1862a96..67cdca65 100644
--- a/scripts/patch-guacamole-lite.cjs
+++ b/scripts/patch-guacamole-lite.cjs
@@ -26,10 +26,31 @@ if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
-// Patch 1: version acceptance list
-const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
-const newVersionCheck =
- "if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
+// Patch 1: protocol version negotiation.
+// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
+// versions Termix can handle, and conservatively answer future 1.x versions as
+// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
+// claiming support for unknown instructions.
+const oldVersionBlock =
+ " if (version === '1_0_0' || version === '1_1_0') {\n" +
+ " protocolVersion = version;\n" +
+ " } else {\n" +
+ " protocolVersion = '1_1_0';\n" +
+ " }";
+const oldPatchedVersionBlock =
+ " if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
+ " protocolVersion = version;\n" +
+ " } else {\n" +
+ " protocolVersion = '1_1_0';\n" +
+ " }";
+const newVersionBlock =
+ " if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
+ " protocolVersion = version;\n" +
+ " } else if (/^1_\\d+_0$/.test(version)) {\n" +
+ " protocolVersion = '1_5_0';\n" +
+ " } else {\n" +
+ " protocolVersion = '1_1_0';\n" +
+ " }";
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
const oldTimezone = "if (protocolVersion === '1_1_0') {";
@@ -105,17 +126,23 @@ const newReadyHandler =
let patched = false;
-if (!guacdClientContent.includes(newVersionCheck)) {
- if (!guacdClientContent.includes(oldVersionCheck)) {
+if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
+ if (guacdClientContent.includes(oldPatchedVersionBlock)) {
+ guacdClientContent = guacdClientContent.replace(
+ oldPatchedVersionBlock,
+ newVersionBlock,
+ );
+ } else if (guacdClientContent.includes(oldVersionBlock)) {
+ guacdClientContent = guacdClientContent.replace(
+ oldVersionBlock,
+ newVersionBlock,
+ );
+ } else {
console.log(
"[patch-guacamole-lite] Version check target not found, skipping",
);
process.exit(0);
}
- guacdClientContent = guacdClientContent.replace(
- oldVersionCheck,
- newVersionCheck,
- );
patched = true;
}
diff --git a/scripts/patch-guacamole-lite.test.ts b/scripts/patch-guacamole-lite.test.ts
index 755d7174..d6b8c9eb 100644
--- a/scripts/patch-guacamole-lite.test.ts
+++ b/scripts/patch-guacamole-lite.test.ts
@@ -1,6 +1,29 @@
import fs from "node:fs";
+import { createRequire } from "node:module";
import path from "node:path";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+
+const require = createRequire(import.meta.url);
+const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
+
+type PatchedGuacdClient = {
+ connectionSettings: Record;
+ nextArgumentStreamIndex: number;
+ sendInstruction: ReturnType;
+ sendHandshakeReply: (serverHandshake: string[]) => void;
+ sendRequiredArguments: (params: string[]) => void;
+};
+
+function createPatchedClient(
+ connectionSettings: Record,
+): PatchedGuacdClient {
+ return Object.assign(Object.create(GuacdClient.prototype), {
+ connectionSettings,
+ logger: { log: vi.fn() },
+ nextArgumentStreamIndex: 0,
+ sendInstruction: vi.fn(),
+ });
+}
describe("patch-guacamole-lite", () => {
it("handles guacd dynamic argument requests", () => {
@@ -20,4 +43,49 @@ describe("patch-guacamole-lite", () => {
expect(content).toContain("this.sendInstruction(['blob'");
expect(content).toContain("this.sendInstruction(['end'");
});
+
+ it("keeps required-argument support when guacd offers a future 1.x protocol", () => {
+ const client = createPatchedClient({
+ hostname: "192.0.2.10",
+ port: 5900,
+ password: "secret",
+ width: 1280,
+ height: 720,
+ dpi: 96,
+ });
+
+ client.sendHandshakeReply(["VERSION_1_6_0", "hostname", "port"]);
+
+ expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
+ expect(client.sendInstruction).toHaveBeenCalledWith([
+ "name",
+ "guacamole-lite",
+ ]);
+ expect(client.sendInstruction).toHaveBeenCalledWith([
+ "connect",
+ "VERSION_1_5_0",
+ "192.0.2.10",
+ 5900,
+ ]);
+ });
+
+ it("answers required credentials through argument value streams", () => {
+ const client = createPatchedClient({
+ username: "",
+ password: "secret",
+ });
+
+ client.sendRequiredArguments(["username", "password"]);
+
+ expect(
+ client.sendInstruction.mock.calls.map(([instruction]) => instruction),
+ ).toEqual([
+ ["argv", 0, "text/plain", "username"],
+ ["blob", 0, ""],
+ ["end", 0],
+ ["argv", 1, "text/plain", "password"],
+ ["blob", 1, Buffer.from("secret", "utf8").toString("base64")],
+ ["end", 1],
+ ]);
+ });
});
diff --git a/scripts/patch-xterm-android-ime.cjs b/scripts/patch-xterm-android-ime.cjs
new file mode 100644
index 00000000..e7738b32
--- /dev/null
+++ b/scripts/patch-xterm-android-ime.cjs
@@ -0,0 +1,85 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+const xtermDir = path.join(
+ __dirname,
+ "..",
+ "node_modules",
+ "@xterm",
+ "xterm",
+ "lib",
+);
+
+// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
+// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
+// composition on the previous word and replace it with a shorter value (for
+// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
+const patches = [
+ {
+ file: "xterm.mjs",
+ replacements: [
+ [
+ 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
+ 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
+ ],
+ [
+ 'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
+ 'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
+ ],
+ [
+ "let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
+ "let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
+ ],
+ [
+ "e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
+ "e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length0&&",
+ ],
+ ],
+ },
+ {
+ file: "xterm.js",
+ replacements: [
+ [
+ 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
+ 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
+ ],
+ [
+ 'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
+ 'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
+ ],
+ [
+ "const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
+ "const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
+ ],
+ [
+ "e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
+ "e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length0&&",
+ ],
+ ],
+ },
+];
+
+for (const { file, replacements } of patches) {
+ const filePath = path.join(xtermDir, file);
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
+ }
+
+ let source = fs.readFileSync(filePath, "utf8");
+ if (source.includes("_preCompositionValue")) {
+ console.log(`[patch-xterm-android-ime] ${file} already patched`);
+ continue;
+ }
+
+ for (const [original, patched] of replacements) {
+ if (!source.includes(original)) {
+ throw new Error(
+ `[patch-xterm-android-ime] Expected source not found in ${file}`,
+ );
+ }
+ source = source.replace(original, patched);
+ }
+
+ fs.writeFileSync(filePath, source);
+ console.log(`[patch-xterm-android-ime] Patched ${file}`);
+}
diff --git a/scripts/sync-version.cjs b/scripts/sync-version.cjs
index e177c1a9..ce111724 100644
--- a/scripts/sync-version.cjs
+++ b/scripts/sync-version.cjs
@@ -1,7 +1,7 @@
const fs = require("fs");
const path = require("path");
-const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
+const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/;
function readJsonWithTrailingNewline(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
diff --git a/scripts/sync-version.test.ts b/scripts/sync-version.test.ts
index dbfb5bbb..f2fa7788 100644
--- a/scripts/sync-version.test.ts
+++ b/scripts/sync-version.test.ts
@@ -69,6 +69,13 @@ describe("syncVersion", () => {
expect(() => syncVersion("2.4", { root })).toThrow(/invalid version/);
});
+ it("accepts a prerelease suffix", () => {
+ const changed = syncVersion("2.6.0-beta.20260720", { root });
+ expect(changed).toEqual(["package.json", "package-lock.json"]);
+ expect(pkg().version).toBe("2.6.0-beta.20260720");
+ expect(lock().version).toBe("2.6.0-beta.20260720");
+ });
+
it("works when only the lock root version is stale", () => {
fs.writeFileSync(
path.join(root, "package.json"),
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index c2e0997d..66914a15 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -11,7 +11,7 @@ import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
-import guacamoleRoutes from "../guacamole/routes.js";
+import guacamoleRoutes from "../hosts/guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
@@ -34,27 +34,25 @@ import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
-import { eq, and } from "drizzle-orm";
+import {
+ createCurrentCredentialRepository,
+ createCurrentDismissedAlertRepository,
+ createCurrentFileManagerBookmarkRepository,
+ createCurrentHostRepository,
+ createCurrentSettingsRepository,
+ createCurrentSshCredentialUsageRepository,
+ createCurrentUserRepository,
+} from "./repositories/factory.js";
+import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
-import {
- users,
- hosts,
- sshCredentials,
- fileManagerRecent,
- fileManagerPinned,
- fileManagerShortcuts,
- dismissedAlerts,
- sshCredentialUsage,
- settings,
-} from "./db/schema.js";
import type {
CacheEntry,
GitHubRelease,
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
-import { getDb, DatabaseSaveTrigger } from "./db/index.js";
+import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -70,6 +68,45 @@ const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
app.use(createCorsMiddleware());
+type SettingData = {
+ key: string;
+ value: string;
+};
+
+function shouldExportSetting(key: string): boolean {
+ return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
+}
+
+async function getExportableSettings(): Promise {
+ const settingsRows = await createCurrentSettingsRepository().listAll();
+
+ return settingsRows.filter((setting) => shouldExportSetting(setting.key));
+}
+
+function writeSettingsToExportDatabase(
+ exportDb: Database.Database,
+ settingsRows: SettingData[],
+): void {
+ const insertSetting = exportDb.prepare(`
+ INSERT INTO settings (key, value)
+ VALUES (?, ?)
+ `);
+
+ for (const setting of settingsRows) {
+ insertSetting.run(setting.key, setting.value);
+ }
+}
+
+function readImportedSettings(importDb: Database.Database): SettingData[] {
+ return importDb
+ .prepare("SELECT key, value FROM settings")
+ .all() as SettingData[];
+}
+
+async function upsertImportedSetting(setting: SettingData): Promise {
+ await createCurrentSettingsRepository().upsert(setting.key, setting.value);
+}
+
const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
const storage = multer.diskStorage({
@@ -621,12 +658,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const deviceInfo = parseUserAgent(req);
- const user = await getDb().select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0) {
+ const userRepository = createCurrentUserRepository();
+ const user = await userRepository.findById(userId);
+ if (!user) {
return res.status(404).json({ error: "User not found" });
}
- const isOidcUser = !!user[0].isOidc;
+ const isOidcUser = !!user.isOidc;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
@@ -867,22 +905,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
userRecord.totpBackupCodes || null,
);
- const sshHosts = await getDb()
- .select()
- .from(hosts)
- .where(eq(hosts.userId, userId));
+ const sshHosts =
+ await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
- for (const host of sshHosts) {
- const decrypted = DataCrypto.decryptRecord(
- "ssh_data",
- host,
- userId,
- userDataKey,
- );
+ for (const decrypted of sshHosts) {
insertHost.run(
decrypted.id,
decrypted.userId,
@@ -940,22 +970,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
- const credentials = await getDb()
- .select()
- .from(sshCredentials)
- .where(eq(sshCredentials.userId, userId));
+ const credentials =
+ await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const insertCred = exportDb.prepare(`
INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
- for (const cred of credentials) {
- const decrypted = DataCrypto.decryptRecord(
- "ssh_credentials",
- cred,
- userId,
- userDataKey,
- );
+ for (const decrypted of credentials) {
insertCred.run(
decrypted.id,
decrypted.userId,
@@ -979,19 +1001,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
+ const fileManagerRepository =
+ createCurrentFileManagerBookmarkRepository();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
- getDb()
- .select()
- .from(fileManagerRecent)
- .where(eq(fileManagerRecent.userId, userId)),
- getDb()
- .select()
- .from(fileManagerPinned)
- .where(eq(fileManagerPinned.userId, userId)),
- getDb()
- .select()
- .from(fileManagerShortcuts)
- .where(eq(fileManagerShortcuts.userId, userId)),
+ fileManagerRepository.listRecentByUserId(userId),
+ fileManagerRepository.listPinnedByUserId(userId),
+ fileManagerRepository.listShortcutsByUserId(userId),
]);
const insertRecent = exportDb.prepare(`
@@ -1039,10 +1054,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
- const alerts = await getDb()
- .select()
- .from(dismissedAlerts)
- .where(eq(dismissedAlerts.userId, userId));
+ const dismissedAlertRepository = createCurrentDismissedAlertRepository();
+ const alerts = await dismissedAlertRepository.listByUserId(userId);
const insertAlert = exportDb.prepare(`
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
VALUES (?, ?, ?, ?)
@@ -1056,10 +1069,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
- const usage = await getDb()
- .select()
- .from(sshCredentialUsage)
- .where(eq(sshCredentialUsage.userId, userId));
+ const sshCredentialUsageRepository =
+ createCurrentSshCredentialUsageRepository();
+ const usage = await sshCredentialUsageRepository.listByUserId(userId);
const insertUsage = exportDb.prepare(`
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
VALUES (?, ?, ?, ?, ?)
@@ -1074,20 +1086,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
- const settingsData = await getDb().select().from(settings);
- const insertSetting = exportDb.prepare(`
- INSERT INTO settings (key, value)
- VALUES (?, ?)
- `);
- for (const setting of settingsData) {
- if (
- setting.key.startsWith("reset_code_") ||
- setting.key.startsWith("temp_reset_token_")
- ) {
- continue;
- }
- insertSetting.run(setting.key, setting.value);
- }
+ writeSettingsToExportDatabase(exportDb, await getExportableSettings());
} finally {
exportDb.close();
}
@@ -1182,19 +1181,16 @@ app.post(
}
const userId = (req as AuthenticatedRequest).userId;
- const mainDb = getDb();
const deviceInfo = parseUserAgent(req);
- const userRecords = await mainDb
- .select()
- .from(users)
- .where(eq(users.id, userId));
+ const userRepository = createCurrentUserRepository();
+ const userRecord = await userRepository.findById(userId);
- if (!userRecords || userRecords.length === 0) {
+ if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
- const isOidcUser = !!userRecords[0].isOidc;
+ const isOidcUser = !!userRecord.isOidc;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
@@ -1276,332 +1272,287 @@ app.post(
};
try {
- mainDb.$client.exec("PRAGMA foreign_keys = OFF");
- try {
- const importedHosts = importDb
- .prepare("SELECT * FROM ssh_data")
- .all();
- for (const host of importedHosts) {
- try {
- const existing = await mainDb
- .select()
- .from(hosts)
- .where(
- and(
- eq(hosts.userId, userId),
- eq(hosts.ip, host.ip),
- eq(hosts.port, host.port),
- eq(hosts.username, host.username),
- ),
- );
-
- if (existing.length > 0) {
- result.summary.skippedItems++;
- continue;
- }
-
- const hostData = {
- userId: userId,
- name: host.name,
- ip: host.ip,
- port: host.port,
- username: host.username,
- folder: host.folder,
- tags: host.tags,
- pin: Boolean(host.pin),
- authType: host.auth_type,
- forceKeyboardInteractive: host.force_keyboard_interactive,
- password: host.password,
- key: host.key,
- keyPassword: host.key_password,
- keyType: host.key_type,
- sudoPassword: host.sudo_password,
- autostartPassword: host.autostart_password,
- autostartKey: host.autostart_key,
- autostartKeyPassword: host.autostart_key_password,
- credentialId: host.credential_id || null,
- overrideCredentialUsername: Boolean(
- host.override_credential_username,
- ),
- enableTerminal: Boolean(host.enable_terminal),
- enableTunnel: Boolean(host.enable_tunnel),
- tunnelConnections: host.tunnel_connections,
- jumpHosts: host.jump_hosts,
- enableFileManager: Boolean(host.enable_file_manager),
- enableDocker: Boolean(host.enable_docker),
- showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
- showFileManagerInSidebar: Boolean(
- host.show_file_manager_in_sidebar,
- ),
- showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
- showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
- showServerStatsInSidebar: Boolean(
- host.show_server_stats_in_sidebar,
- ),
- defaultPath: host.default_path,
- statsConfig: host.stats_config,
- terminalConfig: host.terminal_config,
- quickActions: host.quick_actions,
- notes: host.notes,
- useSocks5: Boolean(host.use_socks5),
- socks5Host: host.socks5_host,
- socks5Port: host.socks5_port,
- socks5Username: host.socks5_username,
- socks5Password: host.socks5_password,
- socks5ProxyChain: host.socks5_proxy_chain,
- createdAt: host.created_at || new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- };
-
- const encrypted = DataCrypto.encryptRecord(
- "ssh_data",
- hostData,
- userId,
- userDataKey,
- );
- await mainDb.insert(hosts).values(encrypted);
- result.summary.sshHostsImported++;
- } catch (hostError) {
- result.summary.errors.push(
- `SSH host import error: ${hostError.message}`,
- );
- }
- }
- } catch {
- apiLogger.info("ssh_data table not found in import file, skipping");
- }
-
- try {
- const importedCreds = importDb
- .prepare("SELECT * FROM ssh_credentials")
- .all();
- for (const cred of importedCreds) {
- try {
- const existing = await mainDb
- .select()
- .from(sshCredentials)
- .where(
- and(
- eq(sshCredentials.userId, userId),
- eq(sshCredentials.name, cred.name),
- eq(sshCredentials.username, cred.username),
- ),
- );
-
- if (existing.length > 0) {
- result.summary.skippedItems++;
- continue;
- }
-
- const credData = {
- userId: userId,
- name: cred.name,
- description: cred.description,
- folder: cred.folder,
- tags: cred.tags,
- authType: cred.auth_type,
- username: cred.username,
- password: cred.password,
- key: cred.key,
- privateKey: cred.private_key,
- publicKey: cred.public_key,
- keyPassword: cred.key_password,
- keyType: cred.key_type,
- detectedKeyType: cred.detected_key_type,
- usageCount: cred.usage_count || 0,
- lastUsed: cred.last_used,
- createdAt: cred.created_at || new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- };
-
- const encrypted = DataCrypto.encryptRecord(
- "ssh_credentials",
- credData,
- userId,
- userDataKey,
- );
- await mainDb.insert(sshCredentials).values(encrypted);
- result.summary.sshCredentialsImported++;
- } catch (credError) {
- result.summary.errors.push(
- `SSH credential import error: ${credError.message}`,
- );
- }
- }
- } catch {
- apiLogger.info(
- "ssh_credentials table not found in import file, skipping",
- );
- }
-
- const fileManagerTables = [
- {
- table: "file_manager_recent",
- schema: fileManagerRecent,
- key: "fileManagerItemsImported",
- },
- {
- table: "file_manager_pinned",
- schema: fileManagerPinned,
- key: "fileManagerItemsImported",
- },
- {
- table: "file_manager_shortcuts",
- schema: fileManagerShortcuts,
- key: "fileManagerItemsImported",
- },
- ];
-
- for (const { table, schema, key } of fileManagerTables) {
+ await withCurrentSqliteForeignKeysDisabled(async () => {
try {
- const importedItems = importDb
- .prepare(`SELECT * FROM ${table}`)
+ const importedHosts = importDb
+ .prepare("SELECT * FROM ssh_data")
.all();
- for (const item of importedItems) {
+ for (const host of importedHosts) {
try {
- const existing = await mainDb
- .select()
- .from(schema)
- .where(
- and(
- eq(schema.userId, userId),
- eq(schema.path, item.path),
- eq(schema.name, item.name),
- ),
- );
+ const hostRepository = createCurrentHostRepository();
+ const exists = await hostRepository.existsForImportIdentity(
+ userId,
+ host.ip,
+ host.port,
+ host.username,
+ );
- if (existing.length > 0) {
+ if (exists) {
result.summary.skippedItems++;
continue;
}
- const itemData = {
+ const hostData = {
userId: userId,
- hostId: item.host_id,
- name: item.name,
- path: item.path,
- ...(table === "file_manager_recent" && {
- lastOpened: item.last_opened,
- }),
- ...(table === "file_manager_pinned" && {
- pinnedAt: item.pinned_at,
- }),
- ...(table === "file_manager_shortcuts" && {
- createdAt: item.created_at,
- }),
+ name: host.name,
+ ip: host.ip,
+ port: host.port,
+ username: host.username,
+ folder: host.folder,
+ tags: host.tags,
+ pin: Boolean(host.pin),
+ authType: host.auth_type,
+ forceKeyboardInteractive: host.force_keyboard_interactive,
+ password: host.password,
+ key: host.key,
+ keyPassword: host.key_password,
+ keyType: host.key_type,
+ sudoPassword: host.sudo_password,
+ autostartPassword: host.autostart_password,
+ autostartKey: host.autostart_key,
+ autostartKeyPassword: host.autostart_key_password,
+ credentialId: host.credential_id || null,
+ overrideCredentialUsername: Boolean(
+ host.override_credential_username,
+ ),
+ enableTerminal: Boolean(host.enable_terminal),
+ enableTunnel: Boolean(host.enable_tunnel),
+ tunnelConnections: host.tunnel_connections,
+ jumpHosts: host.jump_hosts,
+ enableFileManager: Boolean(host.enable_file_manager),
+ enableDocker: Boolean(host.enable_docker),
+ showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
+ showFileManagerInSidebar: Boolean(
+ host.show_file_manager_in_sidebar,
+ ),
+ showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
+ showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
+ showServerStatsInSidebar: Boolean(
+ host.show_server_stats_in_sidebar,
+ ),
+ defaultPath: host.default_path,
+ statsConfig: host.stats_config,
+ terminalConfig: host.terminal_config,
+ quickActions: host.quick_actions,
+ notes: host.notes,
+ useSocks5: Boolean(host.use_socks5),
+ socks5Host: host.socks5_host,
+ socks5Port: host.socks5_port,
+ socks5Username: host.socks5_username,
+ socks5Password: host.socks5_password,
+ socks5ProxyChain: host.socks5_proxy_chain,
+ createdAt: host.created_at || new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
};
- await mainDb.insert(schema).values(itemData);
- result.summary[key]++;
- } catch (itemError) {
+ await hostRepository.createEncryptedForUser(userId, hostData);
+ result.summary.sshHostsImported++;
+ } catch (hostError) {
result.summary.errors.push(
- `${table} import error: ${itemError.message}`,
+ `SSH host import error: ${hostError.message}`,
);
}
}
} catch {
- apiLogger.info(`${table} table not found in import file, skipping`);
+ apiLogger.info("ssh_data table not found in import file, skipping");
}
- }
- try {
- const importedAlerts = importDb
- .prepare("SELECT * FROM dismissed_alerts")
- .all();
- for (const alert of importedAlerts) {
- try {
- const existing = await mainDb
- .select()
- .from(dismissedAlerts)
- .where(
- and(
- eq(dismissedAlerts.userId, userId),
- eq(dismissedAlerts.alertId, alert.alert_id),
- ),
+ try {
+ const importedCreds = importDb
+ .prepare("SELECT * FROM ssh_credentials")
+ .all();
+ for (const cred of importedCreds) {
+ try {
+ const credentialRepository =
+ createCurrentCredentialRepository();
+ const exists =
+ await credentialRepository.existsForImportIdentity(
+ userId,
+ cred.name,
+ cred.username,
+ );
+
+ if (exists) {
+ result.summary.skippedItems++;
+ continue;
+ }
+
+ const credData = {
+ userId: userId,
+ name: cred.name,
+ description: cred.description,
+ folder: cred.folder,
+ tags: cred.tags,
+ authType: cred.auth_type,
+ username: cred.username,
+ password: cred.password,
+ key: cred.key,
+ privateKey: cred.private_key,
+ publicKey: cred.public_key,
+ keyPassword: cred.key_password,
+ keyType: cred.key_type,
+ detectedKeyType: cred.detected_key_type,
+ usageCount: cred.usage_count || 0,
+ lastUsed: cred.last_used,
+ createdAt: cred.created_at || new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+
+ await credentialRepository.createEncryptedForUser(
+ userId,
+ credData,
+ );
+ result.summary.sshCredentialsImported++;
+ } catch (credError) {
+ result.summary.errors.push(
+ `SSH credential import error: ${credError.message}`,
);
-
- if (existing.length > 0) {
- result.summary.skippedItems++;
- continue;
}
+ }
+ } catch {
+ apiLogger.info(
+ "ssh_credentials table not found in import file, skipping",
+ );
+ }
- await mainDb.insert(dismissedAlerts).values({
- userId: userId,
- alertId: alert.alert_id,
- dismissedAt: alert.dismissed_at || new Date().toISOString(),
- });
- result.summary.dismissedAlertsImported++;
- } catch (alertError) {
- result.summary.errors.push(
- `Dismissed alert import error: ${alertError.message}`,
+ const fileManagerTables = [
+ {
+ table: "file_manager_recent",
+ key: "fileManagerItemsImported",
+ },
+ {
+ table: "file_manager_pinned",
+ key: "fileManagerItemsImported",
+ },
+ {
+ table: "file_manager_shortcuts",
+ key: "fileManagerItemsImported",
+ },
+ ];
+
+ const fileManagerRepository =
+ createCurrentFileManagerBookmarkRepository();
+
+ for (const { table, key } of fileManagerTables) {
+ try {
+ const importedItems = importDb
+ .prepare(`SELECT * FROM ${table}`)
+ .all();
+ for (const item of importedItems) {
+ try {
+ const bookmark = {
+ hostId: item.host_id,
+ name: item.name,
+ path: item.path,
+ };
+ const created =
+ table === "file_manager_recent"
+ ? await fileManagerRepository.createRecentForImport(
+ userId,
+ bookmark,
+ item.last_opened,
+ )
+ : table === "file_manager_pinned"
+ ? await fileManagerRepository.createPinnedForImport(
+ userId,
+ bookmark,
+ item.pinned_at,
+ )
+ : await fileManagerRepository.createShortcutForImport(
+ userId,
+ bookmark,
+ item.created_at,
+ );
+
+ if (created) {
+ result.summary[key]++;
+ } else {
+ result.summary.skippedItems++;
+ }
+ } catch (itemError) {
+ result.summary.errors.push(
+ `${table} import error: ${itemError.message}`,
+ );
+ }
+ }
+ } catch {
+ apiLogger.info(
+ `${table} table not found in import file, skipping`,
);
}
}
- } catch {
- apiLogger.info(
- "dismissed_alerts table not found in import file, skipping",
- );
- }
- const targetUser = await mainDb
- .select()
- .from(users)
- .where(eq(users.id, userId));
- if (targetUser.length > 0 && targetUser[0].isAdmin) {
+ const dismissedAlertRepository =
+ createCurrentDismissedAlertRepository();
+
try {
- const importedSettings = importDb
- .prepare("SELECT * FROM settings")
+ const importedAlerts = importDb
+ .prepare("SELECT * FROM dismissed_alerts")
.all();
- for (const setting of importedSettings) {
+ for (const alert of importedAlerts) {
try {
- const existing = await mainDb
- .select()
- .from(settings)
- .where(eq(settings.key, setting.key));
-
- if (existing.length > 0) {
- await mainDb
- .update(settings)
- .set({ value: setting.value })
- .where(eq(settings.key, setting.key));
- result.summary.settingsImported++;
+ const created = await dismissedAlertRepository.createForImport(
+ userId,
+ alert.alert_id,
+ alert.dismissed_at,
+ );
+ if (created) {
+ result.summary.dismissedAlertsImported++;
} else {
- await mainDb.insert(settings).values({
- key: setting.key,
- value: setting.value,
- });
- result.summary.settingsImported++;
+ result.summary.skippedItems++;
}
- } catch (settingError) {
+ } catch (alertError) {
result.summary.errors.push(
- `Setting import error (${setting.key}): ${settingError.message}`,
+ `Dismissed alert import error: ${alertError.message}`,
);
}
}
} catch {
- apiLogger.info("settings table not found in import file, skipping");
+ apiLogger.info(
+ "dismissed_alerts table not found in import file, skipping",
+ );
}
- } else {
- apiLogger.info(
- "Settings import skipped - only admin users can import settings",
- );
- }
- mainDb.$client.exec("PRAGMA foreign_keys = ON");
- result.success = true;
+ const targetUser = await userRepository.findById(userId);
+ if (targetUser?.isAdmin) {
+ try {
+ const importedSettings = readImportedSettings(importDb);
+ for (const setting of importedSettings) {
+ try {
+ await upsertImportedSetting(setting);
+ result.summary.settingsImported++;
+ } catch (settingError) {
+ result.summary.errors.push(
+ `Setting import error (${setting.key}): ${settingError.message}`,
+ );
+ }
+ }
+ } catch {
+ apiLogger.info(
+ "settings table not found in import file, skipping",
+ );
+ }
+ } else {
+ apiLogger.info(
+ "Settings import skipped - only admin users can import settings",
+ );
+ }
- try {
- await DatabaseSaveTrigger.forceSave("database_import");
- } catch (saveError) {
- apiLogger.error(
- "Failed to persist imported data to disk",
- saveError,
- {
- operation: "import_force_save_failed",
- userId,
- },
- );
- }
+ result.success = true;
+
+ try {
+ await DatabaseSaveTrigger.forceSave("database_import");
+ } catch (saveError) {
+ apiLogger.error(
+ "Failed to persist imported data to disk",
+ saveError,
+ {
+ operation: "import_force_save_failed",
+ userId,
+ },
+ );
+ }
+ });
} finally {
if (importDb) {
importDb.close();
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 22247c71..cb9c358c 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -25,6 +25,28 @@ let memoryDatabase: Database.Database;
let isNewDatabase = false;
let sqlite: Database.Database;
+function getRawSettingValue(key: string): string | null {
+ const row = sqlite
+ .prepare("SELECT value FROM settings WHERE key = ?")
+ .get(key) as { value?: string } | undefined;
+
+ return row?.value ?? null;
+}
+
+function setRawSettingValue(key: string, value: string): void {
+ sqlite
+ .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
+ .run(key, value);
+}
+
+function ensureRawSettingDefault(key: string, value: string): void {
+ if (getRawSettingValue(key) === null) {
+ sqlite
+ .prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
+ .run(key, value);
+ }
+}
+
async function initializeDatabaseAsync(): Promise {
const systemCrypto = SystemCrypto.getInstance();
@@ -165,7 +187,9 @@ async function initializeCompleteDatabase(): Promise {
scopes TEXT DEFAULT 'openid email profile',
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
- totp_backup_codes TEXT
+ totp_backup_codes TEXT,
+ registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
@@ -460,6 +484,8 @@ async function initializeCompleteDatabase(): Promise {
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
+ protocol TEXT NOT NULL DEFAULT 'ssh',
+ format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
@@ -564,12 +590,30 @@ async function initializeCompleteDatabase(): Promise {
`);
try {
- sqlite.prepare("DELETE FROM user_open_tabs").run();
- databaseLogger.info("Open tabs cleared on startup", {
- operation: "db_init_open_tabs_cleanup",
- });
+ const timeoutRow = sqlite
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
+ )
+ .get() as { value: string } | undefined;
+ const timeoutMinutes = timeoutRow
+ ? parseInt(timeoutRow.value, 10)
+ : 30;
+ const ttlMs =
+ !isNaN(timeoutMinutes) && timeoutMinutes > 0
+ ? timeoutMinutes * 60_000
+ : 30 * 60_000;
+ const cutoff = new Date(Date.now() - ttlMs).toISOString();
+ const result = sqlite
+ .prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
+ .run(cutoff);
+ if (result.changes > 0) {
+ databaseLogger.info("Expired open tabs cleared on startup", {
+ operation: "db_init_open_tabs_cleanup",
+ count: result.changes,
+ });
+ }
} catch (e) {
- databaseLogger.warn("Could not clear open tabs on startup", {
+ databaseLogger.warn("Could not clear expired open tabs on startup", {
operation: "db_init_open_tabs_cleanup_failed",
error: e,
});
@@ -595,16 +639,7 @@ async function initializeCompleteDatabase(): Promise {
migrateSchema();
try {
- const row = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
- .get();
- if (!row) {
- sqlite
- .prepare(
- "INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
- )
- .run();
- }
+ ensureRawSettingDefault("allow_registration", "true");
} catch (e) {
databaseLogger.warn("Could not initialize default settings", {
operation: "db_init",
@@ -613,16 +648,7 @@ async function initializeCompleteDatabase(): Promise {
}
try {
- const row = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
- .get();
- if (!row) {
- sqlite
- .prepare(
- "INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')",
- )
- .run();
- }
+ ensureRawSettingDefault("allow_password_login", "true");
} catch (e) {
databaseLogger.warn("Could not initialize allow_password_login setting", {
operation: "db_init",
@@ -631,16 +657,7 @@ async function initializeCompleteDatabase(): Promise {
}
try {
- const row = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
- .get();
- if (!row) {
- sqlite
- .prepare(
- "INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
- )
- .run();
- }
+ ensureRawSettingDefault("guac_enabled", "true");
} catch (e) {
databaseLogger.warn("Could not initialize guac_enabled setting", {
operation: "db_init",
@@ -649,16 +666,7 @@ async function initializeCompleteDatabase(): Promise {
}
try {
- const row = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
- .get();
- if (!row) {
- sqlite
- .prepare(
- "INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
- )
- .run(getDefaultGuacdUrl());
- }
+ ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
} catch (e) {
databaseLogger.warn("Could not initialize guac_url setting", {
operation: "db_init",
@@ -695,6 +703,17 @@ const addColumnIfNotExists = (
};
const migrateSchema = () => {
+ addColumnIfNotExists(
+ "session_recordings",
+ "protocol",
+ "TEXT NOT NULL DEFAULT 'ssh'",
+ );
+ addColumnIfNotExists(
+ "session_recordings",
+ "format",
+ "TEXT NOT NULL DEFAULT 'text'",
+ );
+
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -747,6 +766,62 @@ const migrateSchema = () => {
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
+ const hadRegisteredAtColumn = (() => {
+ try {
+ sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
+ return true;
+ } catch {
+ return false;
+ }
+ })();
+ // SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults like
+ // CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
+ addColumnIfNotExists("users", "registered_at", "TEXT");
+ if (!hadRegisteredAtColumn) {
+ // Pre-existing users are backdated past the 30 day mark so they see the
+ // donation modal immediately on upgrade instead of waiting a fresh
+ // 30 days as if they had just registered.
+ try {
+ sqlite.exec(
+ `UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
+ );
+ } catch (backfillError) {
+ databaseLogger.warn("Failed to backfill users.registered_at", {
+ operation: "schema_migration",
+ error:
+ backfillError instanceof Error
+ ? backfillError.message
+ : String(backfillError),
+ });
+ }
+ } else {
+ try {
+ sqlite.exec(
+ `UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL`,
+ );
+ } catch (backfillError) {
+ databaseLogger.warn(
+ "Failed to backfill NULL users.registered_at values",
+ {
+ operation: "schema_migration",
+ error:
+ backfillError instanceof Error
+ ? backfillError.message
+ : String(backfillError),
+ },
+ );
+ }
+ }
+ addColumnIfNotExists(
+ "users",
+ "donation_modal_dismissed",
+ "INTEGER NOT NULL DEFAULT 0",
+ );
+
+ addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
+ addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
+ addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
+
sqlite.exec(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
@@ -918,10 +993,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT");
- addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
- addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
- addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
-
try {
const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
cid: number;
@@ -959,9 +1030,6 @@ const migrateSchema = () => {
private_key TEXT,
public_key TEXT,
detected_key_type TEXT,
- system_password TEXT,
- system_key TEXT,
- system_key_password TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
@@ -1553,6 +1621,8 @@ const migrateSchema = () => {
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
+ protocol TEXT NOT NULL DEFAULT 'ssh',
+ format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
@@ -1569,37 +1639,54 @@ const migrateSchema = () => {
}
try {
- sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
+ sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
} catch {
try {
sqlite.exec(`
- CREATE TABLE IF NOT EXISTS shared_credentials (
+ CREATE TABLE IF NOT EXISTS shared_host_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_access_id INTEGER NOT NULL,
- original_credential_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
- encrypted_username TEXT NOT NULL,
- encrypted_auth_type TEXT NOT NULL,
+ protocol TEXT NOT NULL DEFAULT 'ssh',
+ source_type TEXT NOT NULL DEFAULT 'credential',
+ original_credential_id INTEGER,
+ encrypted_username TEXT,
+ encrypted_auth_type TEXT,
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
encrypted_key_type TEXT,
+ encrypted_domain TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
- needs_re_encryption INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(host_access_id, target_user_id, protocol),
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
- databaseLogger.warn("Failed to create shared_credentials table", {
+ databaseLogger.warn("Failed to create shared_host_secrets table", {
operation: "schema_migration",
error: createError,
});
}
}
+ try {
+ if (getRawSettingValue("rbac_permission_levels_v2") === null) {
+ sqlite.exec(
+ "UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
+ );
+ setRawSettingValue("rbac_permission_levels_v2", "done");
+ }
+ } catch (migrateError) {
+ databaseLogger.warn("Failed to migrate legacy view permission level", {
+ operation: "schema_migration",
+ error: migrateError,
+ });
+ }
+
try {
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
} catch {
@@ -1900,31 +1987,30 @@ const migrateSchema = () => {
// Migrate legacy single oidc_config settings blob into sso_providers table
try {
- const migrationDone = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
- .get();
+ const migrationDone = getRawSettingValue("sso_migration_v1");
if (!migrationDone) {
const providerCount = (
- sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { c: number }
+ sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
+ c: number;
+ }
).c;
if (providerCount === 0) {
- const legacyRow = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
- .get() as { value: string } | undefined;
- if (legacyRow) {
+ const legacyConfig = getRawSettingValue("oidc_config");
+ if (legacyConfig) {
sqlite
.prepare(
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
)
- .run("OIDC", legacyRow.value);
- databaseLogger.info("Migrated legacy oidc_config into sso_providers table", {
- operation: "sso_migration_v1",
- });
+ .run("OIDC", legacyConfig);
+ databaseLogger.info(
+ "Migrated legacy oidc_config into sso_providers table",
+ {
+ operation: "sso_migration_v1",
+ },
+ );
}
}
- sqlite
- .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
- .run();
+ setRawSettingValue("sso_migration_v1", "true");
}
} catch (e) {
databaseLogger.warn("Failed to run SSO migration v1", {
@@ -2063,19 +2149,15 @@ const migrateSchema = () => {
// Seed default metrics history retention setting
try {
- const retentionRow = sqlite
- .prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'")
- .get();
- if (!retentionRow) {
- sqlite
- .prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')")
- .run();
- }
+ ensureRawSettingDefault("metrics_history_retention_days", "7");
} catch (e) {
- databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
- operation: "schema_migration",
- error: e,
- });
+ databaseLogger.warn(
+ "Could not initialize metrics_history_retention_days setting",
+ {
+ operation: "schema_migration",
+ error: e,
+ },
+ );
}
// --- homepage begin ---
@@ -2166,21 +2248,23 @@ async function saveMemoryDatabaseToFile(): Promise {
}
async function handlePostInitFileEncryption() {
- if (!enableFileEncryption) return;
-
try {
if (memoryDatabase) {
- await saveMemoryDatabaseToFile();
+ DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
+
+ if (enableFileEncryption) {
+ await saveMemoryDatabaseToFile();
+ }
setInterval(() => {
if (DatabaseSaveTrigger.isDirty) {
saveMemoryDatabaseToFile();
}
}, 5 * 60 * 1000);
-
- DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
}
+ if (!enableFileEncryption) return;
+
try {
const migration = new DatabaseMigration(dataDir);
migration.cleanupOldBackups();
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index fabad70f..6e0a3050 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -24,6 +24,13 @@ export const users = sqliteTable("users", {
.notNull()
.default(false),
totpBackupCodes: text("totp_backup_codes"),
+
+ registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
+ donationModalDismissed: integer("donation_modal_dismissed", {
+ mode: "boolean",
+ })
+ .notNull()
+ .default(false),
});
export const settings = sqliteTable("settings", {
@@ -54,6 +61,9 @@ export const sessions = sqliteTable("sessions", {
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
+ oidcSub: text("oidc_sub"),
+ oidcSid: text("oidc_sid"),
+ ssoProviderId: integer("sso_provider_id"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -341,9 +351,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
certPublicKey: text("cert_public_key", { length: 8192 }),
- systemPassword: text("system_password"),
- systemKey: text("system_key", { length: 16384 }),
- systemKeyPassword: text("system_key_password"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
@@ -523,7 +530,7 @@ export const hostAccess = sqliteTable("host_access", {
permissionLevel: text("permission_level")
.notNull()
- .default("view"),
+ .default("connect"),
expiresAt: text("expires_at"),
@@ -538,27 +545,32 @@ export const hostAccess = sqliteTable("host_access", {
),
});
-export const sharedCredentials = sqliteTable("shared_credentials", {
+export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostAccessId: integer("host_access_id")
.notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }),
- originalCredentialId: integer("original_credential_id")
- .notNull()
- .references(() => sshCredentials.id, { onDelete: "cascade" }),
-
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
- encryptedUsername: text("encrypted_username").notNull(),
- encryptedAuthType: text("encrypted_auth_type").notNull(),
+ protocol: text("protocol").notNull().default("ssh"),
+ sourceType: text("source_type").notNull().default("credential"),
+
+ originalCredentialId: integer("original_credential_id").references(
+ () => sshCredentials.id,
+ { onDelete: "cascade" },
+ ),
+
+ encryptedUsername: text("encrypted_username"),
+ encryptedAuthType: text("encrypted_auth_type"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
+ encryptedDomain: text("encrypted_domain"),
createdAt: text("created_at")
.notNull()
@@ -566,10 +578,6 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
-
- needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
- .notNull()
- .default(false),
});
export const roles = sqliteTable("roles", {
@@ -657,6 +665,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
+ protocol: text("protocol").notNull().default("ssh"),
+ format: text("format").notNull().default("text"),
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false),
diff --git a/src/backend/database/repositories/alert-repository.ts b/src/backend/database/repositories/alert-repository.ts
new file mode 100644
index 00000000..9b55ca36
--- /dev/null
+++ b/src/backend/database/repositories/alert-repository.ts
@@ -0,0 +1,622 @@
+import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm";
+import {
+ alertFirings,
+ alertRuleChannels,
+ alertRules,
+ hosts,
+ notificationChannels,
+} from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+type AlertRuleRecord = typeof alertRules.$inferSelect;
+type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
+type AlertFiringRecord = typeof alertFirings.$inferSelect;
+
+export interface NotificationChannelRow {
+ id: number;
+ user_id: string;
+ name: string;
+ type: string;
+ config: string;
+ enabled: number;
+ created_at: string;
+}
+
+export interface AlertRuleRow {
+ id: number;
+ user_id: string;
+ host_id: number | null;
+ name: string;
+ enabled: number;
+ trigger_type: string;
+ threshold_value: number | null;
+ threshold_duration_seconds: number | null;
+ cooldown_minutes: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AlertRuleWithChannelsRow extends AlertRuleRow {
+ channels: number[];
+}
+
+export interface AlertFiringRow {
+ id: number;
+ user_id: string;
+ rule_id: number;
+ host_id: number;
+ host_name: string;
+ fired_at: string;
+ resolved_at: string | null;
+ value: number | null;
+ message: string;
+ severity: string;
+ acknowledged: number;
+ rule_name: string | null;
+}
+
+export interface AlertEngineRule {
+ id: number;
+ userId: string;
+ hostId: number | null;
+ name: string;
+ enabled: boolean;
+ triggerType: string;
+ thresholdValue: number | null;
+ thresholdDurationSeconds: number | null;
+ cooldownMinutes: number;
+}
+
+export interface AlertEngineChannel {
+ id: number;
+ type: string;
+ config: string;
+ enabled: boolean;
+}
+
+export class AlertRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listNotificationChannels(
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(notificationChannels)
+ .where(eq(notificationChannels.userId, userId))
+ .orderBy(notificationChannels.id);
+
+ return rows.map(mapChannelRow);
+ }
+
+ async findNotificationChannelForUser(
+ id: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(notificationChannels)
+ .where(
+ and(
+ eq(notificationChannels.id, id),
+ eq(notificationChannels.userId, userId),
+ ),
+ )
+ .limit(1);
+
+ return rows[0] ? mapChannelRow(rows[0]) : null;
+ }
+
+ async createNotificationChannel(input: {
+ userId: string;
+ name: string;
+ type: string;
+ config: string;
+ enabled: boolean;
+ }): Promise {
+ const [created] = await this.context.drizzle
+ .insert(notificationChannels)
+ .values({
+ userId: input.userId,
+ name: input.name,
+ type: input.type,
+ config: input.config,
+ enabled: input.enabled,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return mapChannelRow(created);
+ }
+
+ async updateNotificationChannel(
+ id: number,
+ userId: string,
+ input: {
+ name?: string;
+ type?: string;
+ config?: string;
+ enabled?: boolean;
+ },
+ ): Promise {
+ if (Object.keys(input).length === 0) {
+ return this.findNotificationChannelForUser(id, userId);
+ }
+
+ const [updated] = await this.context.drizzle
+ .update(notificationChannels)
+ .set(input)
+ .where(
+ and(
+ eq(notificationChannels.id, id),
+ eq(notificationChannels.userId, userId),
+ ),
+ )
+ .returning();
+
+ if (!updated) return null;
+ await this.afterWrite();
+ return mapChannelRow(updated);
+ }
+
+ async deleteNotificationChannel(
+ id: number,
+ userId: string,
+ ): Promise {
+ const deleted = await this.context.drizzle
+ .delete(notificationChannels)
+ .where(
+ and(
+ eq(notificationChannels.id, id),
+ eq(notificationChannels.userId, userId),
+ ),
+ )
+ .returning({ id: notificationChannels.id });
+
+ if (deleted.length === 0) return false;
+ await this.afterWrite();
+ return true;
+ }
+
+ async listAlertRules(userId: string): Promise {
+ const rules = await this.context.drizzle
+ .select()
+ .from(alertRules)
+ .where(eq(alertRules.userId, userId))
+ .orderBy(alertRules.id);
+
+ const result: AlertRuleWithChannelsRow[] = [];
+ for (const rule of rules) {
+ result.push({
+ ...mapRuleRow(rule),
+ channels: await this.listChannelIdsForRule(rule.id),
+ });
+ }
+ return result;
+ }
+
+ async createAlertRule(input: {
+ userId: string;
+ hostId: number | null;
+ name: string;
+ enabled: boolean;
+ triggerType: string;
+ thresholdValue: number | null;
+ thresholdDurationSeconds: number | null;
+ cooldownMinutes: number;
+ channels: number[];
+ now: string;
+ }): Promise {
+ const [created] = await this.context.drizzle
+ .insert(alertRules)
+ .values({
+ userId: input.userId,
+ hostId: input.hostId,
+ name: input.name,
+ enabled: input.enabled,
+ triggerType: input.triggerType,
+ thresholdValue: input.thresholdValue,
+ thresholdDurationSeconds: input.thresholdDurationSeconds,
+ cooldownMinutes: input.cooldownMinutes,
+ createdAt: input.now,
+ updatedAt: input.now,
+ })
+ .returning();
+
+ const channels = await this.replaceRuleChannels(
+ created.id,
+ input.userId,
+ input.channels,
+ );
+ await this.afterWrite();
+ return { ...mapRuleRow(created), channels };
+ }
+
+ async findAlertRuleForUser(
+ id: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(alertRules)
+ .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
+ .limit(1);
+
+ return rows[0] ? mapRuleRow(rows[0]) : null;
+ }
+
+ async updateAlertRule(
+ id: number,
+ userId: string,
+ input: {
+ name?: string;
+ hostId?: number | null;
+ enabled?: boolean;
+ triggerType?: string;
+ thresholdValue?: number | null;
+ thresholdDurationSeconds?: number | null;
+ cooldownMinutes?: number;
+ channels?: number[];
+ now: string;
+ },
+ ): Promise {
+ const [updated] = await this.context.drizzle
+ .update(alertRules)
+ .set({
+ ...(input.name !== undefined ? { name: input.name } : {}),
+ ...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
+ ...(input.triggerType !== undefined
+ ? { triggerType: input.triggerType }
+ : {}),
+ ...(input.thresholdValue !== undefined
+ ? { thresholdValue: input.thresholdValue }
+ : {}),
+ ...(input.thresholdDurationSeconds !== undefined
+ ? { thresholdDurationSeconds: input.thresholdDurationSeconds }
+ : {}),
+ ...(input.cooldownMinutes !== undefined
+ ? { cooldownMinutes: input.cooldownMinutes }
+ : {}),
+ updatedAt: input.now,
+ })
+ .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
+ .returning();
+
+ if (!updated) return null;
+
+ const channels =
+ input.channels === undefined
+ ? await this.listChannelIdsForRule(id)
+ : await this.replaceRuleChannels(id, userId, input.channels);
+
+ await this.afterWrite();
+ return { ...mapRuleRow(updated), channels };
+ }
+
+ async deleteAlertRule(id: number, userId: string): Promise {
+ const deleted = await this.context.drizzle
+ .delete(alertRules)
+ .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
+ .returning({ id: alertRules.id });
+
+ if (deleted.length === 0) return false;
+ await this.afterWrite();
+ return true;
+ }
+
+ async listAlertFirings(input: {
+ userId: string;
+ acknowledged?: boolean;
+ limit: number;
+ offset: number;
+ }): Promise<{ firings: AlertFiringRow[]; total: number }> {
+ const filters = [eq(alertFirings.userId, input.userId)];
+ if (input.acknowledged !== undefined) {
+ filters.push(eq(alertFirings.acknowledged, input.acknowledged));
+ }
+
+ const where = and(...filters);
+ const rows = await this.context.drizzle
+ .select({
+ firing: alertFirings,
+ ruleName: alertRules.name,
+ })
+ .from(alertFirings)
+ .leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId))
+ .where(where)
+ .orderBy(desc(alertFirings.firedAt))
+ .limit(input.limit)
+ .offset(input.offset);
+
+ const totalRows = await this.context.drizzle
+ .select({ total: count() })
+ .from(alertFirings)
+ .where(where);
+
+ return {
+ firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)),
+ total: totalRows[0]?.total ?? 0,
+ };
+ }
+
+ async acknowledgeFiring(id: number, userId: string): Promise {
+ await this.context.drizzle
+ .update(alertFirings)
+ .set({ acknowledged: true })
+ .where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId)));
+ await this.afterWrite();
+ }
+
+ async acknowledgeAllFirings(userId: string): Promise {
+ await this.context.drizzle
+ .update(alertFirings)
+ .set({ acknowledged: true })
+ .where(eq(alertFirings.userId, userId));
+ await this.afterWrite();
+ }
+
+ async listEnabledRulesForHost(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(alertRules)
+ .where(
+ and(
+ eq(alertRules.enabled, true),
+ or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
+ ),
+ );
+ return rows.map(mapEngineRule);
+ }
+
+ async listEnabledRulesForHostUser(
+ hostId: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(alertRules)
+ .where(
+ and(
+ eq(alertRules.enabled, true),
+ eq(alertRules.userId, userId),
+ or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
+ ),
+ );
+ return rows.map(mapEngineRule);
+ }
+
+ async findRuleById(id: number): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(alertRules)
+ .where(eq(alertRules.id, id))
+ .limit(1);
+ return rows[0] ? mapEngineRule(rows[0]) : null;
+ }
+
+ async createFiring(input: {
+ userId: string;
+ ruleId: number;
+ hostId: number;
+ hostName: string;
+ value: number | null;
+ message: string;
+ severity: string;
+ }): Promise {
+ await this.context.drizzle.insert(alertFirings).values(input);
+ await this.afterWrite();
+ }
+
+ pruneFiringsOlderThan(userId: string, days: number): void {
+ this.context.sqlite
+ ?.prepare(
+ "DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)",
+ )
+ .run(userId, `-${days} days`);
+ }
+
+ async deleteByUserId(userId: string): Promise<{
+ firingsDeleted: number;
+ ruleLinksDeleted: number;
+ rulesDeleted: number;
+ channelsDeleted: number;
+ }> {
+ const ruleIds = (
+ await this.context.drizzle
+ .select({ id: alertRules.id })
+ .from(alertRules)
+ .where(eq(alertRules.userId, userId))
+ ).map((row) => row.id);
+ const channelIds = (
+ await this.context.drizzle
+ .select({ id: notificationChannels.id })
+ .from(notificationChannels)
+ .where(eq(notificationChannels.userId, userId))
+ ).map((row) => row.id);
+
+ const firingRows = await this.context.drizzle
+ .delete(alertFirings)
+ .where(eq(alertFirings.userId, userId))
+ .returning({ id: alertFirings.id });
+
+ const linkFilters = [
+ ...(ruleIds.length > 0
+ ? [inArray(alertRuleChannels.ruleId, ruleIds)]
+ : []),
+ ...(channelIds.length > 0
+ ? [inArray(alertRuleChannels.channelId, channelIds)]
+ : []),
+ ];
+ const linkRows =
+ linkFilters.length === 0
+ ? []
+ : await this.context.drizzle
+ .delete(alertRuleChannels)
+ .where(or(...linkFilters))
+ .returning({ id: alertRuleChannels.id });
+
+ const ruleRows = await this.context.drizzle
+ .delete(alertRules)
+ .where(eq(alertRules.userId, userId))
+ .returning({ id: alertRules.id });
+ const channelRows = await this.context.drizzle
+ .delete(notificationChannels)
+ .where(eq(notificationChannels.userId, userId))
+ .returning({ id: notificationChannels.id });
+
+ if (
+ firingRows.length > 0 ||
+ linkRows.length > 0 ||
+ ruleRows.length > 0 ||
+ channelRows.length > 0
+ ) {
+ await this.afterWrite();
+ }
+
+ return {
+ firingsDeleted: firingRows.length,
+ ruleLinksDeleted: linkRows.length,
+ rulesDeleted: ruleRows.length,
+ channelsDeleted: channelRows.length,
+ };
+ }
+
+ async listEnabledChannelsForRule(
+ ruleId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({
+ id: notificationChannels.id,
+ type: notificationChannels.type,
+ config: notificationChannels.config,
+ enabled: notificationChannels.enabled,
+ })
+ .from(notificationChannels)
+ .innerJoin(
+ alertRuleChannels,
+ eq(alertRuleChannels.channelId, notificationChannels.id),
+ )
+ .where(
+ and(
+ eq(alertRuleChannels.ruleId, ruleId),
+ eq(notificationChannels.enabled, true),
+ ),
+ );
+
+ return rows;
+ }
+
+ async getHostDisplayName(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .select({ name: hosts.name, ip: hosts.ip })
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
+ .limit(1);
+
+ const row = rows[0];
+ return row ? row.name || row.ip : null;
+ }
+
+ private async replaceRuleChannels(
+ ruleId: number,
+ userId: string,
+ channelIds: number[],
+ ): Promise {
+ await this.context.drizzle
+ .delete(alertRuleChannels)
+ .where(eq(alertRuleChannels.ruleId, ruleId));
+
+ const linked: number[] = [];
+ for (const channelId of channelIds) {
+ const channel = await this.findNotificationChannelForUser(
+ channelId,
+ userId,
+ );
+ if (!channel) continue;
+ await this.context.drizzle
+ .insert(alertRuleChannels)
+ .values({ ruleId, channelId });
+ linked.push(channelId);
+ }
+ return linked;
+ }
+
+ private async listChannelIdsForRule(ruleId: number): Promise {
+ const rows = await this.context.drizzle
+ .select({ channelId: alertRuleChannels.channelId })
+ .from(alertRuleChannels)
+ .where(eq(alertRuleChannels.ruleId, ruleId));
+
+ return rows.map((row) => row.channelId);
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
+
+function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
+ return {
+ id: row.id,
+ user_id: row.userId,
+ name: row.name,
+ type: row.type,
+ config: row.config,
+ enabled: row.enabled ? 1 : 0,
+ created_at: row.createdAt,
+ };
+}
+
+function mapRuleRow(row: AlertRuleRecord): AlertRuleRow {
+ return {
+ id: row.id,
+ user_id: row.userId,
+ host_id: row.hostId,
+ name: row.name,
+ enabled: row.enabled ? 1 : 0,
+ trigger_type: row.triggerType,
+ threshold_value: row.thresholdValue,
+ threshold_duration_seconds: row.thresholdDurationSeconds,
+ cooldown_minutes: row.cooldownMinutes,
+ created_at: row.createdAt,
+ updated_at: row.updatedAt,
+ };
+}
+
+function mapFiringRow(
+ row: AlertFiringRecord,
+ ruleName: string | null,
+): AlertFiringRow {
+ return {
+ id: row.id,
+ user_id: row.userId,
+ rule_id: row.ruleId,
+ host_id: row.hostId,
+ host_name: row.hostName,
+ fired_at: row.firedAt,
+ resolved_at: row.resolvedAt,
+ value: row.value,
+ message: row.message,
+ severity: row.severity,
+ acknowledged: row.acknowledged ? 1 : 0,
+ rule_name: ruleName,
+ };
+}
+
+function mapEngineRule(row: AlertRuleRecord): AlertEngineRule {
+ return {
+ id: row.id,
+ userId: row.userId,
+ hostId: row.hostId,
+ name: row.name,
+ enabled: row.enabled,
+ triggerType: row.triggerType,
+ thresholdValue: row.thresholdValue,
+ thresholdDurationSeconds: row.thresholdDurationSeconds,
+ cooldownMinutes: row.cooldownMinutes,
+ };
+}
diff --git a/src/backend/database/repositories/api-key-repository.ts b/src/backend/database/repositories/api-key-repository.ts
new file mode 100644
index 00000000..a24b880a
--- /dev/null
+++ b/src/backend/database/repositories/api-key-repository.ts
@@ -0,0 +1,103 @@
+import { eq, and } from "drizzle-orm";
+import { apiKeys, users } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type ApiKeyRecord = typeof apiKeys.$inferSelect;
+export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
+
+export interface ApiKeyListRecord {
+ id: string;
+ name: string;
+ userId: string;
+ username: string | null;
+ tokenPrefix: string;
+ createdAt: string;
+ expiresAt: string | null;
+ lastUsedAt: string | null;
+ isActive: boolean;
+}
+
+export class ApiKeyRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(apiKey: NewApiKeyRecord): Promise {
+ const rows = await this.context.drizzle
+ .insert(apiKeys)
+ .values(apiKey)
+ .returning();
+ await this.afterWrite();
+ return rows[0];
+ }
+
+ async listAllWithUsers(): Promise {
+ return this.context.drizzle
+ .select({
+ id: apiKeys.id,
+ name: apiKeys.name,
+ userId: apiKeys.userId,
+ username: users.username,
+ tokenPrefix: apiKeys.tokenPrefix,
+ createdAt: apiKeys.createdAt,
+ expiresAt: apiKeys.expiresAt,
+ lastUsedAt: apiKeys.lastUsedAt,
+ isActive: apiKeys.isActive,
+ })
+ .from(apiKeys)
+ .leftJoin(users, eq(apiKeys.userId, users.id))
+ .orderBy(apiKeys.createdAt);
+ }
+
+ async findById(id: string): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(apiKeys)
+ .where(eq(apiKeys.id, id))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async listActiveByTokenPrefix(tokenPrefix: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(apiKeys)
+ .where(
+ and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
+ );
+ }
+
+ async updateLastUsedAt(id: string, lastUsedAt: string): Promise {
+ await this.context.drizzle
+ .update(apiKeys)
+ .set({ lastUsedAt })
+ .where(eq(apiKeys.id, id));
+ await this.afterWrite();
+ }
+
+ async delete(id: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(apiKeys)
+ .where(eq(apiKeys.id, id))
+ .returning();
+
+ await this.afterWrite();
+ return rows[0] ?? null;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(apiKeys)
+ .where(eq(apiKeys.userId, userId))
+ .returning({ id: apiKeys.id });
+
+ await this.afterWrite();
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/audit-log-repository.ts b/src/backend/database/repositories/audit-log-repository.ts
new file mode 100644
index 00000000..6e9c86a9
--- /dev/null
+++ b/src/backend/database/repositories/audit-log-repository.ts
@@ -0,0 +1,135 @@
+import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
+import { auditLogs } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type AuditLogRecord = typeof auditLogs.$inferSelect;
+export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
+
+export type AuditLogFilters = {
+ userId?: string;
+ action?: string;
+ resourceType?: string;
+ success?: boolean;
+ startDate?: string;
+ endDate?: string;
+};
+
+export type AuditLogPage = {
+ logs: AuditLogRecord[];
+ total: number;
+};
+
+const PRUNE_MAX = 10000;
+const PRUNE_TARGET = 9000;
+
+export class AuditLogRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(entry: NewAuditLogRecord): Promise {
+ await this.context.drizzle.insert(auditLogs).values(entry);
+ await this.pruneIfNeeded();
+ await this.afterWrite();
+ }
+
+ async listPage(input: {
+ filters: AuditLogFilters;
+ limit: number;
+ offset: number;
+ }): Promise {
+ const whereClause = this.buildWhere(input.filters);
+
+ const [logs, totalResult] = await Promise.all([
+ this.context.drizzle
+ .select()
+ .from(auditLogs)
+ .where(whereClause)
+ .orderBy(desc(auditLogs.timestamp))
+ .limit(input.limit)
+ .offset(input.offset),
+ this.context.drizzle
+ .select({ count: sql`COUNT(*)` })
+ .from(auditLogs)
+ .where(whereClause),
+ ]);
+
+ return {
+ logs,
+ total: totalResult[0]?.count ?? 0,
+ };
+ }
+
+ async listDistinctActions(): Promise {
+ const rows = await this.context.drizzle
+ .selectDistinct({ action: auditLogs.action })
+ .from(auditLogs)
+ .orderBy(asc(auditLogs.action));
+
+ return rows.map((row) => row.action);
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(auditLogs)
+ .where(eq(auditLogs.userId, userId))
+ .returning({ id: auditLogs.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private buildWhere(filters: AuditLogFilters) {
+ const conditions = [];
+
+ if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId));
+ if (filters.action) conditions.push(eq(auditLogs.action, filters.action));
+ if (filters.resourceType) {
+ conditions.push(eq(auditLogs.resourceType, filters.resourceType));
+ }
+ if (filters.success !== undefined) {
+ conditions.push(eq(auditLogs.success, filters.success));
+ }
+ if (filters.startDate) {
+ conditions.push(gte(auditLogs.timestamp, filters.startDate));
+ }
+ if (filters.endDate) {
+ conditions.push(lte(auditLogs.timestamp, filters.endDate));
+ }
+
+ return conditions.length > 0 ? and(...conditions) : undefined;
+ }
+
+ private async pruneIfNeeded(): Promise {
+ const countResult = await this.context.drizzle
+ .select({ count: sql`COUNT(*)` })
+ .from(auditLogs);
+ const count = countResult[0]?.count ?? 0;
+
+ if (count < PRUNE_MAX) {
+ return;
+ }
+
+ const deleteCount = count - PRUNE_TARGET;
+ const rows = await this.context.drizzle
+ .select({ id: auditLogs.id })
+ .from(auditLogs)
+ .orderBy(asc(auditLogs.timestamp))
+ .limit(deleteCount);
+ const ids = rows.map((row) => row.id);
+
+ if (ids.length > 0) {
+ await this.context.drizzle
+ .delete(auditLogs)
+ .where(inArray(auditLogs.id, ids));
+ }
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/c2s-tunnel-preset-repository.ts b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts
new file mode 100644
index 00000000..b0417132
--- /dev/null
+++ b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts
@@ -0,0 +1,136 @@
+import { and, asc, eq, sql } from "drizzle-orm";
+import { c2sTunnelPresets } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
+
+export interface C2sTunnelPresetCreateInput {
+ name: string;
+ config: string;
+ platform?: string | null;
+ computerName?: string | null;
+}
+
+export type C2sTunnelPresetUpdateInput = Partial;
+
+export class C2sTunnelPresetRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(c2sTunnelPresets)
+ .where(eq(c2sTunnelPresets.userId, userId))
+ .orderBy(asc(c2sTunnelPresets.name));
+ }
+
+ async findByIdForUser(
+ userId: string,
+ id: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(c2sTunnelPresets)
+ .where(
+ and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
+ )
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async hasNameForUser(
+ userId: string,
+ name: string,
+ excludingId?: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: c2sTunnelPresets.id })
+ .from(c2sTunnelPresets)
+ .where(
+ and(
+ eq(c2sTunnelPresets.userId, userId),
+ eq(c2sTunnelPresets.name, name),
+ ),
+ );
+
+ return rows.some((row) => row.id !== excludingId);
+ }
+
+ async createForUser(
+ userId: string,
+ input: C2sTunnelPresetCreateInput,
+ ): Promise {
+ const [created] = await this.context.drizzle
+ .insert(c2sTunnelPresets)
+ .values({
+ userId,
+ name: input.name,
+ config: input.config,
+ platform: input.platform ?? null,
+ computerName: input.computerName ?? null,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return created;
+ }
+
+ async updateForUser(
+ userId: string,
+ id: number,
+ updates: C2sTunnelPresetUpdateInput,
+ ): Promise {
+ const [updated] = await this.context.drizzle
+ .update(c2sTunnelPresets)
+ .set({
+ ...updates,
+ updatedAt: sql`CURRENT_TIMESTAMP`,
+ })
+ .where(
+ and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
+ )
+ .returning();
+
+ if (updated) {
+ await this.afterWrite();
+ }
+
+ return updated ?? null;
+ }
+
+ async deleteForUser(userId: string, id: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(c2sTunnelPresets)
+ .where(
+ and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
+ )
+ .returning({ id: c2sTunnelPresets.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length > 0;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(c2sTunnelPresets)
+ .where(eq(c2sTunnelPresets.userId, userId))
+ .returning({ id: c2sTunnelPresets.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/command-history-repository.ts b/src/backend/database/repositories/command-history-repository.ts
new file mode 100644
index 00000000..5bf72b3a
--- /dev/null
+++ b/src/backend/database/repositories/command-history-repository.ts
@@ -0,0 +1,161 @@
+import { and, desc, eq, inArray, sql } from "drizzle-orm";
+import { commandHistory } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
+
+export class CommandHistoryRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(
+ userId: string,
+ hostId: number,
+ command: string,
+ executedAt = new Date().toISOString(),
+ ): Promise {
+ const [created] = await this.context.drizzle
+ .insert(commandHistory)
+ .values({ userId, hostId, command, executedAt })
+ .returning();
+ await this.afterWrite();
+ return created;
+ }
+
+ async listUniqueCommandsForHost(
+ userId: string,
+ hostId: number,
+ limit = 500,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({
+ command: commandHistory.command,
+ maxExecutedAt: sql`MAX(${commandHistory.executedAt})`,
+ })
+ .from(commandHistory)
+ .where(
+ and(
+ eq(commandHistory.userId, userId),
+ eq(commandHistory.hostId, hostId),
+ ),
+ )
+ .groupBy(commandHistory.command)
+ .orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
+ .limit(limit);
+
+ return rows.map((row) => row.command);
+ }
+
+ async listCommandsForHost(
+ userId: string,
+ hostId: number,
+ limit = 200,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({
+ id: commandHistory.id,
+ command: commandHistory.command,
+ })
+ .from(commandHistory)
+ .where(
+ and(
+ eq(commandHistory.userId, userId),
+ eq(commandHistory.hostId, hostId),
+ ),
+ )
+ .orderBy(desc(commandHistory.executedAt))
+ .limit(limit);
+
+ return rows.map((row) => row.command);
+ }
+
+ async deleteCommandForHost(
+ userId: string,
+ hostId: number,
+ command: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .delete(commandHistory)
+ .where(
+ and(
+ eq(commandHistory.userId, userId),
+ eq(commandHistory.hostId, hostId),
+ eq(commandHistory.command, command),
+ ),
+ )
+ .returning({ id: commandHistory.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteByUserAndHost(userId: string, hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(commandHistory)
+ .where(
+ and(
+ eq(commandHistory.userId, userId),
+ eq(commandHistory.hostId, hostId),
+ ),
+ )
+ .returning({ id: commandHistory.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteByHostId(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(commandHistory)
+ .where(eq(commandHistory.hostId, hostId))
+ .returning({ id: commandHistory.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteByHostIds(hostIds: number[]): Promise {
+ if (hostIds.length === 0) {
+ return 0;
+ }
+
+ const rows = await this.context.drizzle
+ .delete(commandHistory)
+ .where(inArray(commandHistory.hostId, hostIds))
+ .returning({ id: commandHistory.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(commandHistory)
+ .where(eq(commandHistory.userId, userId))
+ .returning({ id: commandHistory.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts
new file mode 100644
index 00000000..82d0af36
--- /dev/null
+++ b/src/backend/database/repositories/credential-repository.ts
@@ -0,0 +1,307 @@
+import { and, desc, eq, sql } from "drizzle-orm";
+import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+import { DataCrypto } from "../../utils/data-crypto.js";
+
+export type CredentialRecord = typeof sshCredentials.$inferSelect;
+export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
+export type CredentialUpdate = Partial<
+ Omit
+>;
+
+export class CredentialRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(credential: NewCredentialRecord): Promise {
+ const rows = await this.context.drizzle
+ .insert(sshCredentials)
+ .values(credential)
+ .returning();
+ await this.afterWrite();
+ return rows[0];
+ }
+
+ async createEncryptedForUser(
+ userId: string,
+ credential: NewCredentialRecord | Record,
+ ): Promise {
+ const userDataKey = DataCrypto.validateUserAccess(userId);
+ const tempId = credential.id ?? Date.now();
+ const dataWithTempId = { ...credential, id: tempId };
+ const encryptedCredential = this.encryptCredentialRecordForWrite(
+ dataWithTempId,
+ userId,
+ userDataKey,
+ );
+
+ if (!credential.id) {
+ delete (encryptedCredential as Partial).id;
+ }
+
+ const rows = await this.context.drizzle
+ .insert(sshCredentials)
+ .values(encryptedCredential as NewCredentialRecord)
+ .returning();
+
+ await this.afterWrite();
+ return DataCrypto.decryptRecord(
+ "ssh_credentials",
+ rows[0],
+ userId,
+ userDataKey,
+ );
+ }
+
+ async findByIdForUser(
+ userId: string,
+ credentialId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, userId),
+ ),
+ )
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async findById(credentialId: number): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sshCredentials)
+ .where(eq(sshCredentials.id, credentialId))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async listByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(sshCredentials)
+ .where(eq(sshCredentials.userId, userId))
+ .orderBy(desc(sshCredentials.updatedAt));
+ }
+
+ async existsForImportIdentity(
+ userId: string,
+ name: string,
+ username: string | null,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: sshCredentials.id })
+ .from(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.userId, userId),
+ eq(sshCredentials.name, name),
+ eq(sshCredentials.username, username),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ async findDecryptedByIdForUser(
+ userId: string,
+ credentialId: number,
+ ): Promise {
+ const row = await this.findByIdForUser(userId, credentialId);
+ return this.decryptOne(row, userId);
+ }
+
+ async listDecryptedByUserId(userId: string): Promise {
+ const rows = await this.listByUserId(userId);
+ return this.decryptMany(rows, userId);
+ }
+
+ async listFolders(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .select({ folder: sshCredentials.folder })
+ .from(sshCredentials)
+ .where(eq(sshCredentials.userId, userId));
+
+ return [...new Set(rows.map((row) => row.folder).filter(Boolean))].sort();
+ }
+
+ async renameFolder(
+ userId: string,
+ oldName: string,
+ newName: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .update(sshCredentials)
+ .set({ folder: newName })
+ .where(
+ and(
+ eq(sshCredentials.userId, userId),
+ eq(sshCredentials.folder, oldName),
+ ),
+ )
+ .returning({ id: sshCredentials.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async updateForUser(
+ userId: string,
+ credentialId: number,
+ update: CredentialUpdate,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .update(sshCredentials)
+ .set(update)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, userId),
+ ),
+ )
+ .returning();
+
+ await this.afterWrite();
+ return rows[0] ?? null;
+ }
+
+ async updateEncryptedForUser(
+ userId: string,
+ credentialId: number,
+ update: CredentialUpdate,
+ ): Promise {
+ const userDataKey = DataCrypto.validateUserAccess(userId);
+ const encryptedUpdate = this.encryptCredentialRecordForWrite(
+ update,
+ userId,
+ userDataKey,
+ );
+
+ const rows = await this.context.drizzle
+ .update(sshCredentials)
+ .set(encryptedUpdate)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, userId),
+ ),
+ )
+ .returning();
+
+ await this.afterWrite();
+ return this.decryptOne(rows[0] ?? null, userId);
+ }
+
+ async deleteForUser(userId: string, credentialId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, userId),
+ ),
+ )
+ .returning({ id: sshCredentials.id });
+
+ await this.afterWrite();
+ return rows.length > 0;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(sshCredentials)
+ .where(eq(sshCredentials.userId, userId))
+ .returning({ id: sshCredentials.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async recordUsage(
+ userId: string,
+ credentialId: number,
+ hostId: number,
+ usedAt = new Date().toISOString(),
+ ): Promise {
+ await this.context.drizzle.insert(sshCredentialUsage).values({
+ credentialId,
+ hostId,
+ userId,
+ usedAt,
+ });
+
+ await this.context.drizzle
+ .update(sshCredentials)
+ .set({
+ lastUsed: usedAt,
+ usageCount: sql`${sshCredentials.usageCount} + 1`,
+ })
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, userId),
+ ),
+ );
+ await this.afterWrite();
+ }
+
+ private decryptOne>(
+ record: T | null,
+ userId: string,
+ ): T | null {
+ if (!record) return null;
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return null;
+ return DataCrypto.decryptRecord(
+ "ssh_credentials",
+ record,
+ userId,
+ userDataKey,
+ );
+ }
+
+ private decryptMany>(
+ records: T[],
+ userId: string,
+ ): T[] {
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return [];
+ return DataCrypto.decryptRecords(
+ "ssh_credentials",
+ records,
+ userId,
+ userDataKey,
+ );
+ }
+
+ private encryptCredentialRecordForWrite>(
+ record: T,
+ userId: string,
+ userDataKey: Buffer,
+ ): T {
+ return DataCrypto.encryptRecord(
+ "ssh_credentials",
+ record,
+ userId,
+ userDataKey,
+ );
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/dashboard-service-link-repository.ts b/src/backend/database/repositories/dashboard-service-link-repository.ts
new file mode 100644
index 00000000..a12079e0
--- /dev/null
+++ b/src/backend/database/repositories/dashboard-service-link-repository.ts
@@ -0,0 +1,129 @@
+import { and, asc, eq } from "drizzle-orm";
+import { dashboardServiceLinks } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type DashboardServiceLinkRecord =
+ typeof dashboardServiceLinks.$inferSelect;
+
+export type DashboardServiceLinkUpdate = Partial<{
+ label: string;
+ url: string;
+}>;
+
+export class DashboardServiceLinkRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(dashboardServiceLinks)
+ .where(eq(dashboardServiceLinks.userId, userId))
+ .orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
+ }
+
+ async createForUser(
+ userId: string,
+ input: { label: string; url: string },
+ createdAt = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.context.drizzle
+ .select({ order: dashboardServiceLinks.order })
+ .from(dashboardServiceLinks)
+ .where(eq(dashboardServiceLinks.userId, userId))
+ .orderBy(asc(dashboardServiceLinks.order));
+ const nextOrder =
+ existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
+
+ const [created] = await this.context.drizzle
+ .insert(dashboardServiceLinks)
+ .values({
+ userId,
+ label: input.label,
+ url: input.url,
+ order: nextOrder,
+ createdAt,
+ })
+ .returning();
+ await this.afterWrite();
+ return created;
+ }
+
+ async findByIdForUser(
+ userId: string,
+ id: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(dashboardServiceLinks)
+ .where(
+ and(
+ eq(dashboardServiceLinks.id, id),
+ eq(dashboardServiceLinks.userId, userId),
+ ),
+ )
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async updateForUser(
+ userId: string,
+ id: number,
+ updates: DashboardServiceLinkUpdate,
+ ): Promise {
+ const [updated] = await this.context.drizzle
+ .update(dashboardServiceLinks)
+ .set(updates)
+ .where(
+ and(
+ eq(dashboardServiceLinks.id, id),
+ eq(dashboardServiceLinks.userId, userId),
+ ),
+ )
+ .returning();
+
+ if (updated) {
+ await this.afterWrite();
+ }
+
+ return updated ?? null;
+ }
+
+ async deleteForUser(userId: string, id: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(dashboardServiceLinks)
+ .where(
+ and(
+ eq(dashboardServiceLinks.id, id),
+ eq(dashboardServiceLinks.userId, userId),
+ ),
+ )
+ .returning({ id: dashboardServiceLinks.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length > 0;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(dashboardServiceLinks)
+ .where(eq(dashboardServiceLinks.userId, userId))
+ .returning({ id: dashboardServiceLinks.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/database-context.ts b/src/backend/database/repositories/database-context.ts
new file mode 100644
index 00000000..7666cc41
--- /dev/null
+++ b/src/backend/database/repositories/database-context.ts
@@ -0,0 +1,9 @@
+import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
+import type { Database as BetterSqliteDatabase } from "better-sqlite3";
+import type * as schema from "../db/schema.js";
+
+export interface DatabaseContext {
+ dialect: "sqlite";
+ drizzle: BetterSQLite3Database;
+ sqlite?: BetterSqliteDatabase;
+}
diff --git a/src/backend/database/repositories/dismissed-alert-repository.ts b/src/backend/database/repositories/dismissed-alert-repository.ts
new file mode 100644
index 00000000..e44d20b1
--- /dev/null
+++ b/src/backend/database/repositories/dismissed-alert-repository.ts
@@ -0,0 +1,108 @@
+import { and, eq } from "drizzle-orm";
+import { dismissedAlerts } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
+
+export class DismissedAlertRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(dismissedAlerts)
+ .where(eq(dismissedAlerts.userId, userId));
+ }
+
+ async listAlertIdsByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .select({ alertId: dismissedAlerts.alertId })
+ .from(dismissedAlerts)
+ .where(eq(dismissedAlerts.userId, userId));
+
+ return rows.map((row) => row.alertId);
+ }
+
+ async findForUser(
+ userId: string,
+ alertId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(dismissedAlerts)
+ .where(
+ and(
+ eq(dismissedAlerts.userId, userId),
+ eq(dismissedAlerts.alertId, alertId),
+ ),
+ )
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async create(userId: string, alertId: string): Promise {
+ await this.context.drizzle.insert(dismissedAlerts).values({
+ userId,
+ alertId,
+ });
+ await this.afterWrite();
+ }
+
+ async createForImport(
+ userId: string,
+ alertId: string,
+ dismissedAt = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.findForUser(userId, alertId);
+ if (existing) {
+ return false;
+ }
+
+ await this.context.drizzle.insert(dismissedAlerts).values({
+ userId,
+ alertId,
+ dismissedAt,
+ });
+ await this.afterWrite();
+ return true;
+ }
+
+ async deleteForUser(userId: string, alertId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(dismissedAlerts)
+ .where(
+ and(
+ eq(dismissedAlerts.userId, userId),
+ eq(dismissedAlerts.alertId, alertId),
+ ),
+ )
+ .returning({ id: dismissedAlerts.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length > 0;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(dismissedAlerts)
+ .where(eq(dismissedAlerts.userId, userId))
+ .returning({ id: dismissedAlerts.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts
new file mode 100644
index 00000000..0db7fea1
--- /dev/null
+++ b/src/backend/database/repositories/factory.ts
@@ -0,0 +1,356 @@
+import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
+import { getDb, getSqlite } from "../db/index.js";
+import type { DatabaseContext } from "./database-context.js";
+import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
+import { AlertRepository } from "./alert-repository.js";
+import { ApiKeyRepository } from "./api-key-repository.js";
+import { AuditLogRepository } from "./audit-log-repository.js";
+import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
+import { CommandHistoryRepository } from "./command-history-repository.js";
+import { CredentialRepository } from "./credential-repository.js";
+import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
+import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
+import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
+import { HomepageItemRepository } from "./homepage-item-repository.js";
+import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
+import { HostFolderRepository } from "./host-folder-repository.js";
+import { HostHealthRepository } from "./host-health-repository.js";
+import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
+import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
+import { HostRepository } from "./host-repository.js";
+import { HostResolutionRepository } from "./host-resolution-repository.js";
+import { NetworkTopologyRepository } from "./network-topology-repository.js";
+import { OpenTabRepository } from "./open-tab-repository.js";
+import { OpksshTokenRepository } from "./opkssh-token-repository.js";
+import { RbacAccessRepository } from "./rbac-access-repository.js";
+import { RecentActivityRepository } from "./recent-activity-repository.js";
+import { RoleRepository } from "./role-repository.js";
+import { SessionRecordingRepository } from "./session-recording-repository.js";
+import { SessionRepository } from "./session-repository.js";
+import { SettingsRepository } from "./settings-repository.js";
+import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
+import { SnippetRepository } from "./snippet-repository.js";
+import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
+import { SsoProviderRepository } from "./sso-provider-repository.js";
+import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
+import { TermixIdentityRepository } from "./termix-identity-repository.js";
+import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
+import { TransferRecentRepository } from "./transfer-recent-repository.js";
+import { TrustedDeviceRepository } from "./trusted-device-repository.js";
+import { UserDataExportRepository } from "./user-data-export-repository.js";
+import { UserPreferenceRepository } from "./user-preference-repository.js";
+import { UserRepository } from "./user-repository.js";
+import { VaultProfileRepository } from "./vault-profile-repository.js";
+import { VaultTokenRepository } from "./vault-token-repository.js";
+
+export function createCurrentRepositoryContext(): DatabaseContext {
+ return {
+ dialect: "sqlite",
+ drizzle: getDb(),
+ sqlite: getSqlite(),
+ };
+}
+
+export function createCurrentRepositoryWriteHook(
+ reason: string,
+): () => Promise {
+ return () => DatabaseSaveTrigger.forceSave(reason);
+}
+
+export function getCurrentRepositorySqlite() {
+ return getSqlite();
+}
+
+export function getCurrentSettingValue(key: string): string | null {
+ const row = getCurrentRepositorySqlite()
+ .prepare("SELECT value FROM settings WHERE key = ?")
+ .get(key) as { value?: string } | undefined;
+
+ return row?.value ?? null;
+}
+
+export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialRepository {
+ return new WebauthnCredentialRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("webauthn_credential_repository_write"),
+ );
+}
+
+export function createCurrentAlertRepository(): AlertRepository {
+ return new AlertRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("alert_repository_write"),
+ );
+}
+
+export function createCurrentApiKeyRepository(): ApiKeyRepository {
+ return new ApiKeyRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("api_key_repository_write"),
+ );
+}
+
+export function createCurrentAuditLogRepository(): AuditLogRepository {
+ return new AuditLogRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("audit_log_repository_write"),
+ );
+}
+
+export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository {
+ return new C2sTunnelPresetRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"),
+ );
+}
+
+export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
+ return new CommandHistoryRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("command_history_repository_write"),
+ );
+}
+
+export function createCurrentCredentialRepository(): CredentialRepository {
+ return new CredentialRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("credential_repository_write"),
+ );
+}
+
+export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
+ return new DashboardServiceLinkRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
+ );
+}
+
+export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
+ return new DismissedAlertRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
+ );
+}
+
+export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
+ return new FileManagerBookmarkRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
+ );
+}
+
+export function createCurrentHomepageItemRepository(): HomepageItemRepository {
+ return new HomepageItemRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("homepage_item_repository_write"),
+ );
+}
+
+export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
+ return new HomepageLayoutRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
+ );
+}
+
+export function createCurrentHostFolderRepository(): HostFolderRepository {
+ return new HostFolderRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("host_folder_repository_write"),
+ );
+}
+
+export function createCurrentHostHealthRepository(): HostHealthRepository {
+ return new HostHealthRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("host_health_repository_write"),
+ );
+}
+
+export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
+ return new HostMetricsHistoryRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("host_metrics_history_repository_write"),
+ );
+}
+
+export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
+ return new HostMetricsPreferenceRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook(
+ "host_metrics_preference_repository_write",
+ ),
+ );
+}
+
+export function createCurrentHostRepository(): HostRepository {
+ return new HostRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("host_repository_write"),
+ );
+}
+
+export function createCurrentHostResolutionRepository(): HostResolutionRepository {
+ return new HostResolutionRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("host_resolution_repository_write"),
+ );
+}
+
+export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
+ return new NetworkTopologyRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("network_topology_repository_write"),
+ );
+}
+
+export function createCurrentOpenTabRepository(): OpenTabRepository {
+ return new OpenTabRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("open_tab_repository_write"),
+ );
+}
+
+export function createCurrentOpksshTokenRepository(): OpksshTokenRepository {
+ return new OpksshTokenRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("opkssh_token_repository_write"),
+ );
+}
+
+export function createCurrentRbacAccessRepository(): RbacAccessRepository {
+ return new RbacAccessRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("rbac_access_repository_write"),
+ );
+}
+
+export function createCurrentRecentActivityRepository(): RecentActivityRepository {
+ return new RecentActivityRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("recent_activity_repository_write"),
+ );
+}
+
+export function createCurrentRoleRepository(): RoleRepository {
+ return new RoleRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("role_repository_write"),
+ );
+}
+
+export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
+ return new SessionRecordingRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("session_recording_repository_write"),
+ );
+}
+
+export function createCurrentSessionRepository(): SessionRepository {
+ return new SessionRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("session_repository_write"),
+ );
+}
+
+export function createCurrentSettingsRepository(): SettingsRepository {
+ return new SettingsRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("settings_repository_write"),
+ );
+}
+
+export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
+ return new SharedHostSecretsRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
+ );
+}
+
+export function createCurrentSnippetRepository(): SnippetRepository {
+ return new SnippetRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("snippet_repository_write"),
+ );
+}
+
+export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
+ return new SshCredentialUsageRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("ssh_credential_usage_repository_write"),
+ );
+}
+
+export function createCurrentSsoProviderRepository(): SsoProviderRepository {
+ return new SsoProviderRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("sso_provider_repository_write"),
+ );
+}
+
+export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
+ return new TermixIdentityCaRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"),
+ );
+}
+
+export function createCurrentTermixIdentityRepository(): TermixIdentityRepository {
+ return new TermixIdentityRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("termix_identity_repository_write"),
+ );
+}
+
+export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository {
+ return new TmuxSessionTagRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"),
+ );
+}
+
+export function createCurrentTransferRecentRepository(): TransferRecentRepository {
+ return new TransferRecentRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("transfer_recent_repository_write"),
+ );
+}
+
+export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
+ return new TrustedDeviceRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("trusted_device_repository_write"),
+ );
+}
+
+export function createCurrentUserDataExportRepository(): UserDataExportRepository {
+ return new UserDataExportRepository(createCurrentRepositoryContext());
+}
+
+export function createCurrentUserPreferenceRepository(): UserPreferenceRepository {
+ return new UserPreferenceRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("user_preference_repository_write"),
+ );
+}
+
+export function createCurrentUserRepository(): UserRepository {
+ return new UserRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("user_repository_write"),
+ );
+}
+
+export function createCurrentVaultProfileRepository(): VaultProfileRepository {
+ return new VaultProfileRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("vault_profile_repository_write"),
+ );
+}
+
+export function createCurrentVaultTokenRepository(): VaultTokenRepository {
+ return new VaultTokenRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("vault_token_repository_write"),
+ );
+}
diff --git a/src/backend/database/repositories/field-encryption-boundary.ts b/src/backend/database/repositories/field-encryption-boundary.ts
new file mode 100644
index 00000000..04cdf02d
--- /dev/null
+++ b/src/backend/database/repositories/field-encryption-boundary.ts
@@ -0,0 +1,158 @@
+import { FieldCrypto } from "../../utils/field-crypto.js";
+import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
+
+const FIELD_ENCRYPTION_POLICY = {
+ users: {
+ sensitive: new Set([
+ "passwordHash",
+ "clientSecret",
+ "totpSecret",
+ "totpBackupCodes",
+ "oidcIdentifier",
+ ]),
+ plaintext: new Set(["id", "username", "isAdmin", "isOidc"]),
+ },
+ ssh_data: {
+ sensitive: new Set([
+ "password",
+ "key",
+ "keyPassword",
+ "sudoPassword",
+ "autostartPassword",
+ "autostartKey",
+ "autostartKeyPassword",
+ "socks5Password",
+ "rdpPassword",
+ "vncPassword",
+ "telnetPassword",
+ ]),
+ plaintext: new Set([
+ "id",
+ "userId",
+ "connectionType",
+ "name",
+ "ip",
+ "port",
+ "username",
+ "folder",
+ "tags",
+ "authType",
+ "credentialId",
+ ]),
+ },
+ ssh_credentials: {
+ sensitive: new Set([
+ "password",
+ "key",
+ "privateKey",
+ "publicKey",
+ "keyPassword",
+ ]),
+ plaintext: new Set([
+ "id",
+ "userId",
+ "name",
+ "description",
+ "folder",
+ "tags",
+ "authType",
+ "username",
+ "keyType",
+ "detectedKeyType",
+ "usageCount",
+ "lastUsed",
+ ]),
+ },
+ opkssh_tokens: {
+ sensitive: new Set(["sshCert", "privateKey"]),
+ plaintext: new Set(["id", "userId", "hostId", "createdAt", "expiresAt"]),
+ },
+ termix_identity_ca: {
+ sensitive: new Set(["privateKey"]),
+ plaintext: new Set(["id", "publicKey", "createdAt", "updatedAt"]),
+ },
+ vault_tokens: {
+ sensitive: new Set(["sshCert", "privateKey"]),
+ plaintext: new Set(["id", "userId", "profileId", "expiresAt"]),
+ },
+} as const;
+
+type PolicyTable = keyof typeof FIELD_ENCRYPTION_POLICY;
+export type FieldClassification = "sensitive" | "plaintext" | "unknown";
+
+export class FieldEncryptionBoundary {
+ static classifyField(
+ tableName: string,
+ fieldName: string,
+ ): FieldClassification {
+ const policy = this.getPolicy(tableName);
+ if (!policy) return "unknown";
+ if (policy.sensitive.has(fieldName)) return "sensitive";
+ if (policy.plaintext.has(fieldName)) return "plaintext";
+ return "unknown";
+ }
+
+ static getSensitiveFields(tableName: string): string[] {
+ const policy = this.getPolicy(tableName);
+ return policy ? [...policy.sensitive].sort() : [];
+ }
+
+ static encryptRecord>(
+ tableName: string,
+ record: T,
+ userDataKey: Buffer,
+ recordId = record.id,
+ ): T {
+ const id = this.requireRecordId(recordId);
+ const encryptedRecord: Record = { ...record };
+
+ for (const fieldName of this.getSensitiveFields(tableName)) {
+ const value = encryptedRecord[fieldName];
+ if (typeof value === "string" && value) {
+ encryptedRecord[fieldName] = FieldCrypto.encryptField(
+ value,
+ userDataKey,
+ id,
+ fieldName,
+ );
+ }
+ }
+
+ return encryptedRecord as T;
+ }
+
+ static decryptRecord>(
+ tableName: string,
+ record: T,
+ userDataKey: Buffer,
+ recordId = record.id,
+ ): T {
+ const id = this.requireRecordId(recordId);
+ const decryptedRecord: Record = { ...record };
+
+ for (const fieldName of this.getSensitiveFields(tableName)) {
+ const value = decryptedRecord[fieldName];
+ if (typeof value === "string" && value) {
+ decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
+ value,
+ userDataKey,
+ id,
+ fieldName,
+ );
+ }
+ }
+
+ return decryptedRecord as T;
+ }
+
+ private static getPolicy(tableName: string) {
+ return FIELD_ENCRYPTION_POLICY[tableName as PolicyTable];
+ }
+
+ private static requireRecordId(recordId: unknown): string {
+ if (recordId === null || recordId === undefined || recordId === "") {
+ throw new Error("Field encryption requires a stable record id.");
+ }
+ return String(recordId);
+ }
+}
diff --git a/src/backend/database/repositories/file-manager-bookmark-repository.ts b/src/backend/database/repositories/file-manager-bookmark-repository.ts
new file mode 100644
index 00000000..dfd68b3f
--- /dev/null
+++ b/src/backend/database/repositories/file-manager-bookmark-repository.ts
@@ -0,0 +1,533 @@
+import { and, desc, eq, inArray } from "drizzle-orm";
+import {
+ fileManagerPinned,
+ fileManagerRecent,
+ fileManagerShortcuts,
+} from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
+export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
+export type FileManagerShortcutRecord =
+ typeof fileManagerShortcuts.$inferSelect;
+
+export interface FileManagerBookmarkInput {
+ hostId: number;
+ path: string;
+ name?: string | null;
+}
+
+function resolveBookmarkName(path: string, name?: string | null): string {
+ return name || path.split("/").pop() || "Unknown";
+}
+
+export class FileManagerBookmarkRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listRecentForHost(
+ userId: string,
+ hostId: number,
+ limit = 20,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(fileManagerRecent)
+ .where(
+ and(
+ eq(fileManagerRecent.userId, userId),
+ eq(fileManagerRecent.hostId, hostId),
+ ),
+ )
+ .orderBy(desc(fileManagerRecent.lastOpened))
+ .limit(limit);
+ }
+
+ async listRecentByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(fileManagerRecent)
+ .where(eq(fileManagerRecent.userId, userId));
+ }
+
+ async upsertRecent(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ lastOpened = new Date().toISOString(),
+ ): Promise {
+ const [existing] = await this.context.drizzle
+ .select({ id: fileManagerRecent.id })
+ .from(fileManagerRecent)
+ .where(
+ and(
+ eq(fileManagerRecent.userId, userId),
+ eq(fileManagerRecent.hostId, input.hostId),
+ eq(fileManagerRecent.path, input.path),
+ ),
+ )
+ .limit(1);
+
+ if (existing) {
+ await this.context.drizzle
+ .update(fileManagerRecent)
+ .set({ lastOpened })
+ .where(eq(fileManagerRecent.id, existing.id));
+ } else {
+ await this.context.drizzle.insert(fileManagerRecent).values({
+ userId,
+ hostId: input.hostId,
+ path: input.path,
+ name: resolveBookmarkName(input.path, input.name),
+ lastOpened,
+ });
+ }
+
+ await this.afterWrite();
+ }
+
+ async createRecentForImport(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ lastOpened = new Date().toISOString(),
+ ): Promise {
+ const exists = await this.existsRecentImportItem(userId, input);
+ if (exists) {
+ return false;
+ }
+
+ await this.context.drizzle.insert(fileManagerRecent).values({
+ userId,
+ hostId: input.hostId,
+ path: input.path,
+ name: resolveBookmarkName(input.path, input.name),
+ lastOpened,
+ });
+ await this.afterWrite();
+ return true;
+ }
+
+ async deleteRecentForHostPath(
+ userId: string,
+ input: Pick,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerRecent)
+ .where(
+ and(
+ eq(fileManagerRecent.userId, userId),
+ eq(fileManagerRecent.hostId, input.hostId),
+ eq(fileManagerRecent.path, input.path),
+ ),
+ )
+ .returning({ id: fileManagerRecent.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async listPinnedForHost(
+ userId: string,
+ hostId: number,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(fileManagerPinned)
+ .where(
+ and(
+ eq(fileManagerPinned.userId, userId),
+ eq(fileManagerPinned.hostId, hostId),
+ ),
+ )
+ .orderBy(desc(fileManagerPinned.pinnedAt));
+ }
+
+ async listPinnedByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(fileManagerPinned)
+ .where(eq(fileManagerPinned.userId, userId));
+ }
+
+ async createPinned(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ pinnedAt = new Date().toISOString(),
+ ): Promise {
+ const exists = await this.existsPinned(userId, input.hostId, input.path);
+ if (exists) {
+ return false;
+ }
+
+ await this.context.drizzle.insert(fileManagerPinned).values({
+ userId,
+ hostId: input.hostId,
+ path: input.path,
+ name: resolveBookmarkName(input.path, input.name),
+ pinnedAt,
+ });
+ await this.afterWrite();
+ return true;
+ }
+
+ async createPinnedForImport(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ pinnedAt = new Date().toISOString(),
+ ): Promise {
+ const exists = await this.existsPinnedImportItem(userId, input);
+ if (exists) {
+ return false;
+ }
+
+ await this.context.drizzle.insert(fileManagerPinned).values({
+ userId,
+ hostId: input.hostId,
+ path: input.path,
+ name: resolveBookmarkName(input.path, input.name),
+ pinnedAt,
+ });
+ await this.afterWrite();
+ return true;
+ }
+
+ async deletePinnedForHostPath(
+ userId: string,
+ input: Pick,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerPinned)
+ .where(
+ and(
+ eq(fileManagerPinned.userId, userId),
+ eq(fileManagerPinned.hostId, input.hostId),
+ eq(fileManagerPinned.path, input.path),
+ ),
+ )
+ .returning({ id: fileManagerPinned.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async listShortcutsForHost(
+ userId: string,
+ hostId: number,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(fileManagerShortcuts)
+ .where(
+ and(
+ eq(fileManagerShortcuts.userId, userId),
+ eq(fileManagerShortcuts.hostId, hostId),
+ ),
+ )
+ .orderBy(desc(fileManagerShortcuts.createdAt));
+ }
+
+ async listShortcutsByUserId(
+ userId: string,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(fileManagerShortcuts)
+ .where(eq(fileManagerShortcuts.userId, userId));
+ }
+
+ async createShortcut(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ createdAt = new Date().toISOString(),
+ ): Promise {
+ const exists = await this.existsShortcut(userId, input.hostId, input.path);
+ if (exists) {
+ return false;
+ }
+
+ await this.context.drizzle.insert(fileManagerShortcuts).values({
+ userId,
+ hostId: input.hostId,
+ path: input.path,
+ name: resolveBookmarkName(input.path, input.name),
+ createdAt,
+ });
+ await this.afterWrite();
+ return true;
+ }
+
+ async createShortcutForImport(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ createdAt = new Date().toISOString(),
+ ): Promise {
+ const exists = await this.existsShortcutImportItem(userId, input);
+ if (exists) {
+ return false;
+ }
+
+ await this.context.drizzle.insert(fileManagerShortcuts).values({
+ userId,
+ hostId: input.hostId,
+ path: input.path,
+ name: resolveBookmarkName(input.path, input.name),
+ createdAt,
+ });
+ await this.afterWrite();
+ return true;
+ }
+
+ async deleteShortcutForHostPath(
+ userId: string,
+ input: Pick,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerShortcuts)
+ .where(
+ and(
+ eq(fileManagerShortcuts.userId, userId),
+ eq(fileManagerShortcuts.hostId, input.hostId),
+ eq(fileManagerShortcuts.path, input.path),
+ ),
+ )
+ .returning({ id: fileManagerShortcuts.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const total =
+ (await this.deleteRecentByUserId(userId)) +
+ (await this.deletePinnedByUserId(userId)) +
+ (await this.deleteShortcutsByUserId(userId));
+
+ if (total > 0) {
+ await this.afterWrite();
+ }
+
+ return total;
+ }
+
+ async deleteByHostId(hostId: number): Promise {
+ const total =
+ (await this.deleteRecentByHostId(hostId)) +
+ (await this.deletePinnedByHostId(hostId)) +
+ (await this.deleteShortcutsByHostId(hostId));
+
+ if (total > 0) {
+ await this.afterWrite();
+ }
+
+ return total;
+ }
+
+ async deleteByHostIds(hostIds: number[]): Promise {
+ if (hostIds.length === 0) {
+ return 0;
+ }
+
+ const total =
+ (await this.deleteRecentByHostIds(hostIds)) +
+ (await this.deletePinnedByHostIds(hostIds)) +
+ (await this.deleteShortcutsByHostIds(hostIds));
+
+ if (total > 0) {
+ await this.afterWrite();
+ }
+
+ return total;
+ }
+
+ private async existsPinned(
+ userId: string,
+ hostId: number,
+ path: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: fileManagerPinned.id })
+ .from(fileManagerPinned)
+ .where(
+ and(
+ eq(fileManagerPinned.userId, userId),
+ eq(fileManagerPinned.hostId, hostId),
+ eq(fileManagerPinned.path, path),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ private async existsRecentImportItem(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: fileManagerRecent.id })
+ .from(fileManagerRecent)
+ .where(
+ and(
+ eq(fileManagerRecent.userId, userId),
+ eq(fileManagerRecent.path, input.path),
+ eq(
+ fileManagerRecent.name,
+ resolveBookmarkName(input.path, input.name),
+ ),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ private async existsPinnedImportItem(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: fileManagerPinned.id })
+ .from(fileManagerPinned)
+ .where(
+ and(
+ eq(fileManagerPinned.userId, userId),
+ eq(fileManagerPinned.path, input.path),
+ eq(
+ fileManagerPinned.name,
+ resolveBookmarkName(input.path, input.name),
+ ),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ private async existsShortcutImportItem(
+ userId: string,
+ input: FileManagerBookmarkInput,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: fileManagerShortcuts.id })
+ .from(fileManagerShortcuts)
+ .where(
+ and(
+ eq(fileManagerShortcuts.userId, userId),
+ eq(fileManagerShortcuts.path, input.path),
+ eq(
+ fileManagerShortcuts.name,
+ resolveBookmarkName(input.path, input.name),
+ ),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ private async existsShortcut(
+ userId: string,
+ hostId: number,
+ path: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: fileManagerShortcuts.id })
+ .from(fileManagerShortcuts)
+ .where(
+ and(
+ eq(fileManagerShortcuts.userId, userId),
+ eq(fileManagerShortcuts.hostId, hostId),
+ eq(fileManagerShortcuts.path, path),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ private async deleteRecentByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerRecent)
+ .where(eq(fileManagerRecent.userId, userId))
+ .returning({ id: fileManagerRecent.id });
+ return rows.length;
+ }
+
+ private async deletePinnedByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerPinned)
+ .where(eq(fileManagerPinned.userId, userId))
+ .returning({ id: fileManagerPinned.id });
+ return rows.length;
+ }
+
+ private async deleteShortcutsByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerShortcuts)
+ .where(eq(fileManagerShortcuts.userId, userId))
+ .returning({ id: fileManagerShortcuts.id });
+ return rows.length;
+ }
+
+ private async deleteRecentByHostId(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerRecent)
+ .where(eq(fileManagerRecent.hostId, hostId))
+ .returning({ id: fileManagerRecent.id });
+ return rows.length;
+ }
+
+ private async deletePinnedByHostId(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerPinned)
+ .where(eq(fileManagerPinned.hostId, hostId))
+ .returning({ id: fileManagerPinned.id });
+ return rows.length;
+ }
+
+ private async deleteShortcutsByHostId(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerShortcuts)
+ .where(eq(fileManagerShortcuts.hostId, hostId))
+ .returning({ id: fileManagerShortcuts.id });
+ return rows.length;
+ }
+
+ private async deleteRecentByHostIds(hostIds: number[]): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerRecent)
+ .where(inArray(fileManagerRecent.hostId, hostIds))
+ .returning({ id: fileManagerRecent.id });
+ return rows.length;
+ }
+
+ private async deletePinnedByHostIds(hostIds: number[]): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerPinned)
+ .where(inArray(fileManagerPinned.hostId, hostIds))
+ .returning({ id: fileManagerPinned.id });
+ return rows.length;
+ }
+
+ private async deleteShortcutsByHostIds(hostIds: number[]): Promise {
+ const rows = await this.context.drizzle
+ .delete(fileManagerShortcuts)
+ .where(inArray(fileManagerShortcuts.hostId, hostIds))
+ .returning({ id: fileManagerShortcuts.id });
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/homepage-item-repository.ts b/src/backend/database/repositories/homepage-item-repository.ts
new file mode 100644
index 00000000..1dc8efea
--- /dev/null
+++ b/src/backend/database/repositories/homepage-item-repository.ts
@@ -0,0 +1,114 @@
+import { and, asc, eq } from "drizzle-orm";
+import { homepageItems } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type HomepageItemRecord = typeof homepageItems.$inferSelect;
+
+export interface HomepageItemCreateInput {
+ typeId: string;
+ title: string | null;
+ config: string;
+}
+
+export type HomepageItemUpdateInput = Partial<{
+ title: string | null;
+ config: string;
+}>;
+
+export class HomepageItemRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(homepageItems)
+ .where(eq(homepageItems.userId, userId))
+ .orderBy(asc(homepageItems.id));
+ }
+
+ async createForUser(
+ userId: string,
+ input: HomepageItemCreateInput,
+ now = new Date().toISOString(),
+ ): Promise {
+ const [created] = await this.context.drizzle
+ .insert(homepageItems)
+ .values({
+ userId,
+ typeId: input.typeId,
+ title: input.title,
+ config: input.config,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return created;
+ }
+
+ async findByIdForUser(
+ userId: string,
+ id: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(homepageItems)
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async updateForUser(
+ userId: string,
+ id: number,
+ updates: HomepageItemUpdateInput,
+ updatedAt = new Date().toISOString(),
+ ): Promise {
+ const [updated] = await this.context.drizzle
+ .update(homepageItems)
+ .set({ ...updates, updatedAt })
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
+ .returning();
+
+ if (updated) {
+ await this.afterWrite();
+ }
+
+ return updated ?? null;
+ }
+
+ async deleteForUser(userId: string, id: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(homepageItems)
+ .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
+ .returning({ id: homepageItems.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length > 0;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(homepageItems)
+ .where(eq(homepageItems.userId, userId))
+ .returning({ id: homepageItems.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/homepage-layout-repository.ts b/src/backend/database/repositories/homepage-layout-repository.ts
new file mode 100644
index 00000000..bb10c453
--- /dev/null
+++ b/src/backend/database/repositories/homepage-layout-repository.ts
@@ -0,0 +1,64 @@
+import { eq } from "drizzle-orm";
+import { homepageLayouts } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
+
+export class HomepageLayoutRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async findByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(homepageLayouts)
+ .where(eq(homepageLayouts.userId, userId))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async upsertForUser(
+ userId: string,
+ layout: string,
+ updatedAt = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.findByUserId(userId);
+
+ if (!existing) {
+ const [created] = await this.context.drizzle
+ .insert(homepageLayouts)
+ .values({ userId, layout, updatedAt })
+ .returning();
+ await this.afterWrite();
+ return created;
+ }
+
+ const [updated] = await this.context.drizzle
+ .update(homepageLayouts)
+ .set({ layout, updatedAt })
+ .where(eq(homepageLayouts.userId, userId))
+ .returning();
+ await this.afterWrite();
+ return updated;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(homepageLayouts)
+ .where(eq(homepageLayouts.userId, userId))
+ .returning({ id: homepageLayouts.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts
new file mode 100644
index 00000000..50d0b169
--- /dev/null
+++ b/src/backend/database/repositories/host-folder-repository.ts
@@ -0,0 +1,168 @@
+import { and, eq, like, or, sql } from "drizzle-orm";
+import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
+import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type HostFolderRecord = typeof sshFolders.$inferSelect;
+export type HostFolderHostRecord = typeof hosts.$inferSelect;
+
+export interface RenameFolderResult {
+ updatedHosts: number;
+ updatedCredentials: number;
+}
+
+export class HostFolderRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async renameFolder(
+ userId: string,
+ oldName: string,
+ newName: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ const oldPrefix = `${oldName} / `;
+ const newPrefix = `${newName} / `;
+ const childLike = `${oldPrefix}%`;
+ const renameExpr = (col: SQLiteColumn) =>
+ sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
+ const folderMatch = (col: SQLiteColumn) =>
+ or(eq(col, oldName), like(col, childLike));
+
+ const updatedHosts = await this.context.drizzle
+ .update(hosts)
+ .set({ folder: renameExpr(hosts.folder), updatedAt: now })
+ .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
+ .returning({ id: hosts.id });
+
+ const updatedCredentials = await this.context.drizzle
+ .update(sshCredentials)
+ .set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
+ .where(
+ and(
+ eq(sshCredentials.userId, userId),
+ folderMatch(sshCredentials.folder),
+ ),
+ )
+ .returning({ id: sshCredentials.id });
+
+ await this.context.drizzle
+ .update(sshFolders)
+ .set({ name: renameExpr(sshFolders.name), updatedAt: now })
+ .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
+
+ await this.afterWrite();
+ return {
+ updatedHosts: updatedHosts.length,
+ updatedCredentials: updatedCredentials.length,
+ };
+ }
+
+ async listFolders(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(sshFolders)
+ .where(eq(sshFolders.userId, userId));
+ }
+
+ async upsertMetadata(
+ userId: string,
+ name: string,
+ color: string | null | undefined,
+ icon: string | null | undefined,
+ now = new Date().toISOString(),
+ ): Promise<{ folder: HostFolderRecord; created: boolean }> {
+ const existing = await this.findFolder(userId, name);
+ if (existing) {
+ const [updated] = await this.context.drizzle
+ .update(sshFolders)
+ .set({ color, icon, updatedAt: now })
+ .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
+ .returning();
+
+ await this.afterWrite();
+ return { folder: updated, created: false };
+ }
+
+ const [created] = await this.context.drizzle
+ .insert(sshFolders)
+ .values({
+ userId,
+ name,
+ color,
+ icon,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return { folder: created, created: true };
+ }
+
+ async listHostsInFolder(
+ userId: string,
+ folderName: string,
+ ): Promise {
+ const folderMatch = (col: SQLiteColumn) =>
+ or(eq(col, folderName), like(col, `${folderName} / %`));
+
+ return this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
+ }
+
+ async deleteHostsAndFolderRecords(
+ userId: string,
+ folderName: string,
+ ): Promise {
+ const folderMatch = (col: SQLiteColumn) =>
+ or(eq(col, folderName), like(col, `${folderName} / %`));
+
+ const hostsToDelete = await this.listHostsInFolder(userId, folderName);
+ if (hostsToDelete.length > 0) {
+ await this.context.drizzle
+ .delete(hosts)
+ .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
+ }
+
+ await this.context.drizzle
+ .delete(sshFolders)
+ .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
+
+ await this.afterWrite();
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(sshFolders)
+ .where(eq(sshFolders.userId, userId))
+ .returning({ id: sshFolders.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async findFolder(
+ userId: string,
+ name: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sshFolders)
+ .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/host-health-repository.ts b/src/backend/database/repositories/host-health-repository.ts
new file mode 100644
index 00000000..5aac9308
--- /dev/null
+++ b/src/backend/database/repositories/host-health-repository.ts
@@ -0,0 +1,164 @@
+import { and, desc, eq } from "drizzle-orm";
+import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
+export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
+
+export interface HostHealthResultInput {
+ checkId: string;
+ ok: boolean;
+ latencyMs: number | null;
+ detail: string;
+}
+
+export class HostHealthRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async findChecksByUserAndHost(
+ userId: string,
+ hostId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hostHealthChecks)
+ .where(
+ and(
+ eq(hostHealthChecks.userId, userId),
+ eq(hostHealthChecks.hostId, hostId),
+ ),
+ )
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async upsertChecks(
+ userId: string,
+ hostId: number,
+ checks: string,
+ intervalSeconds: number,
+ now = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.findChecksByUserAndHost(userId, hostId);
+ if (existing) {
+ const [updated] = await this.context.drizzle
+ .update(hostHealthChecks)
+ .set({ checks, intervalSeconds, updatedAt: now })
+ .where(eq(hostHealthChecks.id, existing.id))
+ .returning();
+
+ await this.afterWrite();
+ return updated;
+ }
+
+ const [created] = await this.context.drizzle
+ .insert(hostHealthChecks)
+ .values({
+ userId,
+ hostId,
+ checks,
+ intervalSeconds,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return created;
+ }
+
+ async recordHistory(
+ userId: string,
+ hostId: number,
+ results: HostHealthResultInput[],
+ keep: number,
+ now = new Date().toISOString(),
+ ): Promise {
+ if (results.length === 0) {
+ return 0;
+ }
+
+ await this.context.drizzle.insert(hostHealthHistory).values(
+ results.map((result) => ({
+ userId,
+ hostId,
+ checkId: result.checkId,
+ ts: now,
+ ok: result.ok,
+ latencyMs: result.latencyMs,
+ detail: result.detail,
+ })),
+ );
+
+ this.pruneHistory(userId, hostId, keep);
+ await this.afterWrite();
+ return results.length;
+ }
+
+ async listHistory(
+ userId: string,
+ hostId: number,
+ limit: number,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(hostHealthHistory)
+ .where(
+ and(
+ eq(hostHealthHistory.userId, userId),
+ eq(hostHealthHistory.hostId, hostId),
+ ),
+ )
+ .orderBy(desc(hostHealthHistory.ts))
+ .limit(limit);
+ }
+
+ async deleteByUserId(userId: string): Promise<{
+ checksDeleted: number;
+ historyDeleted: number;
+ }> {
+ const historyRows = await this.context.drizzle
+ .delete(hostHealthHistory)
+ .where(eq(hostHealthHistory.userId, userId))
+ .returning({ id: hostHealthHistory.id });
+
+ const checkRows = await this.context.drizzle
+ .delete(hostHealthChecks)
+ .where(eq(hostHealthChecks.userId, userId))
+ .returning({ id: hostHealthChecks.id });
+
+ if (historyRows.length > 0 || checkRows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return {
+ checksDeleted: checkRows.length,
+ historyDeleted: historyRows.length,
+ };
+ }
+
+ private pruneHistory(userId: string, hostId: number, keep: number): void {
+ this.context.sqlite
+ ?.prepare(
+ `DELETE FROM host_health_history
+ WHERE id IN (
+ SELECT id FROM host_health_history
+ WHERE user_id = ? AND host_id = ?
+ AND id NOT IN (
+ SELECT id FROM host_health_history
+ WHERE user_id = ? AND host_id = ?
+ ORDER BY ts DESC LIMIT ?
+ )
+ )`,
+ )
+ .run(userId, hostId, userId, hostId, keep);
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/host-metrics-history-repository.ts b/src/backend/database/repositories/host-metrics-history-repository.ts
new file mode 100644
index 00000000..cbad4e2f
--- /dev/null
+++ b/src/backend/database/repositories/host-metrics-history-repository.ts
@@ -0,0 +1,64 @@
+import { and, asc, eq, gte, lte } from "drizzle-orm";
+import { hostMetricsHistory } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
+
+export interface HostMetricsHistoryCreateInput {
+ hostId: number;
+ cpuPercent?: number | null;
+ memPercent?: number | null;
+ diskPercent?: number | null;
+ netRxBytes?: number | null;
+ netTxBytes?: number | null;
+}
+
+export class HostMetricsHistoryRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(input: HostMetricsHistoryCreateInput): Promise {
+ await this.context.drizzle.insert(hostMetricsHistory).values({
+ hostId: input.hostId,
+ cpuPercent: input.cpuPercent,
+ memPercent: input.memPercent,
+ diskPercent: input.diskPercent,
+ netRxBytes: input.netRxBytes,
+ netTxBytes: input.netTxBytes,
+ });
+
+ await this.afterWrite();
+ }
+
+ pruneOlderThan(hostId: number, retentionDays: number): void {
+ this.context.sqlite
+ ?.prepare(
+ "DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)",
+ )
+ .run(hostId, `-${retentionDays} days`);
+ }
+
+ async listRange(
+ hostId: number,
+ fromTs: string,
+ toTs: string,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(hostMetricsHistory)
+ .where(
+ and(
+ eq(hostMetricsHistory.hostId, hostId),
+ gte(hostMetricsHistory.ts, fromTs),
+ lte(hostMetricsHistory.ts, toTs),
+ ),
+ )
+ .orderBy(asc(hostMetricsHistory.ts));
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/host-metrics-preference-repository.ts b/src/backend/database/repositories/host-metrics-preference-repository.ts
new file mode 100644
index 00000000..070063d7
--- /dev/null
+++ b/src/backend/database/repositories/host-metrics-preference-repository.ts
@@ -0,0 +1,97 @@
+import { and, eq } from "drizzle-orm";
+import { hostMetricsPreferences, hosts } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type HostMetricsPreferenceRecord =
+ typeof hostMetricsPreferences.$inferSelect;
+
+export class HostMetricsPreferenceRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async findByUserAndHost(
+ userId: string,
+ hostId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hostMetricsPreferences)
+ .where(
+ and(
+ eq(hostMetricsPreferences.userId, userId),
+ eq(hostMetricsPreferences.hostId, hostId),
+ ),
+ )
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async upsertLayout(
+ userId: string,
+ hostId: number,
+ layout: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.findByUserAndHost(userId, hostId);
+ if (existing) {
+ const [updated] = await this.context.drizzle
+ .update(hostMetricsPreferences)
+ .set({ layout, updatedAt: now })
+ .where(eq(hostMetricsPreferences.id, existing.id))
+ .returning();
+
+ await this.afterWrite();
+ return updated;
+ }
+
+ const [created] = await this.context.drizzle
+ .insert(hostMetricsPreferences)
+ .values({
+ userId,
+ hostId,
+ layout,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return created;
+ }
+
+ async updateHostStatsConfig(
+ userId: string,
+ hostId: number,
+ statsConfig: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .update(hosts)
+ .set({ statsConfig })
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .returning({ id: hosts.id });
+
+ if (rows.length === 0) return false;
+ await this.afterWrite();
+ return true;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(hostMetricsPreferences)
+ .where(eq(hostMetricsPreferences.userId, userId))
+ .returning({ id: hostMetricsPreferences.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts
new file mode 100644
index 00000000..0cb7a4db
--- /dev/null
+++ b/src/backend/database/repositories/host-repository.ts
@@ -0,0 +1,265 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { hostAccess, hosts } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+import { DataCrypto } from "../../utils/data-crypto.js";
+
+export type HostRecord = typeof hosts.$inferSelect;
+export type NewHostRecord = typeof hosts.$inferInsert;
+export type HostUpdate = Partial>;
+export interface HostBulkUpdateState {
+ id: number;
+ statsConfig: string | null;
+ credentialId: number | null;
+ proxmoxConfig: string | null;
+}
+
+export class HostRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(host: NewHostRecord): Promise {
+ const rows = await this.context.drizzle
+ .insert(hosts)
+ .values(host)
+ .returning();
+ await this.afterWrite();
+ return rows[0];
+ }
+
+ async createEncryptedForUser(
+ userId: string,
+ host: NewHostRecord | Record,
+ ): Promise {
+ const userDataKey = DataCrypto.validateUserAccess(userId);
+ const tempId = host.id ?? Date.now();
+ const dataWithTempId = { ...host, id: tempId };
+ const encryptedHost = DataCrypto.encryptRecord(
+ "ssh_data",
+ dataWithTempId,
+ userId,
+ userDataKey,
+ );
+
+ if (!host.id) {
+ delete (encryptedHost as Partial).id;
+ }
+
+ const rows = await this.context.drizzle
+ .insert(hosts)
+ .values(encryptedHost as NewHostRecord)
+ .returning();
+
+ await this.afterWrite();
+ return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
+ }
+
+ async findById(id: number): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(eq(hosts.id, id))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async findByIdForUser(
+ userId: string,
+ hostId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async findDecryptedByIdAs(
+ userId: string,
+ hostId: number,
+ ): Promise {
+ const row = await this.findById(hostId);
+ if (!row) return null;
+
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return null;
+
+ return DataCrypto.decryptRecord("ssh_data", row, userId, userDataKey);
+ }
+
+ async listProxmoxEnabled(): Promise<
+ Pick[]
+ > {
+ return this.context.drizzle
+ .select({
+ id: hosts.id,
+ userId: hosts.userId,
+ proxmoxConfig: hosts.proxmoxConfig,
+ })
+ .from(hosts)
+ .where(eq(hosts.enableProxmox, true));
+ }
+
+ async listByUserId(userId: string): Promise {
+ return this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(eq(hosts.userId, userId));
+ }
+
+ async listDecryptedByUserId(userId: string): Promise {
+ const rows = await this.listByUserId(userId);
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return [];
+ return DataCrypto.decryptRecords("ssh_data", rows, userId, userDataKey);
+ }
+
+ async existsForImportIdentity(
+ userId: string,
+ ip: string,
+ port: number,
+ username: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: hosts.id })
+ .from(hosts)
+ .where(
+ and(
+ eq(hosts.userId, userId),
+ eq(hosts.ip, ip),
+ eq(hosts.port, port),
+ eq(hosts.username, username),
+ ),
+ )
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ async updateForUser(
+ userId: string,
+ hostId: number,
+ update: HostUpdate,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .update(hosts)
+ .set(update)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .returning();
+
+ await this.afterWrite();
+ return rows[0] ?? null;
+ }
+
+ async updateEncryptedForUser(
+ userId: string,
+ hostId: number,
+ update: HostUpdate,
+ ): Promise {
+ const userDataKey = DataCrypto.validateUserAccess(userId);
+ const encryptedUpdate = DataCrypto.encryptRecord(
+ "ssh_data",
+ update,
+ userId,
+ userDataKey,
+ );
+
+ const rows = await this.context.drizzle
+ .update(hosts)
+ .set(encryptedUpdate)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .returning();
+
+ await this.afterWrite();
+ return rows[0]
+ ? DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey)
+ : null;
+ }
+
+ async listBulkUpdateState(
+ userId: string,
+ hostIds: number[],
+ ): Promise {
+ if (hostIds.length === 0) {
+ return [];
+ }
+
+ return this.context.drizzle
+ .select({
+ id: hosts.id,
+ statsConfig: hosts.statsConfig,
+ credentialId: hosts.credentialId,
+ proxmoxConfig: hosts.proxmoxConfig,
+ })
+ .from(hosts)
+ .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
+ }
+
+ async updateManyForUser(
+ userId: string,
+ hostIds: number[],
+ update: HostUpdate,
+ ): Promise {
+ if (hostIds.length === 0 || Object.keys(update).length === 0) {
+ return 0;
+ }
+
+ const rows = await this.context.drizzle
+ .update(hosts)
+ .set(update)
+ .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
+ .returning({ id: hosts.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteForUser(userId: string, hostId: number): Promise {
+ await this.deleteAccessForHost(hostId);
+
+ const rows = await this.context.drizzle
+ .delete(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .returning({ id: hosts.id });
+
+ await this.afterWrite();
+ return rows.length > 0;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(hosts)
+ .where(eq(hosts.userId, userId))
+ .returning({ id: hosts.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteAccessForHost(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(hostAccess)
+ .where(eq(hostAccess.hostId, hostId))
+ .returning({ id: hostAccess.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts
new file mode 100644
index 00000000..61288d58
--- /dev/null
+++ b/src/backend/database/repositories/host-resolution-repository.ts
@@ -0,0 +1,356 @@
+import { and, eq, inArray, isNotNull } from "drizzle-orm";
+import { hostAccess, hosts, sshCredentials } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+import { DataCrypto } from "../../utils/data-crypto.js";
+
+export type HostResolutionHostRecord = typeof hosts.$inferSelect;
+export type HostResolutionCredentialRecord = typeof sshCredentials.$inferSelect;
+export interface HostKeyVerificationRecord {
+ hostKeyFingerprint: string | null;
+ hostKeyType: string | null;
+ hostKeyAlgorithm: string | null;
+ hostKeyChangedCount: number | null;
+ name: string | null;
+}
+export interface HostUpdateStateRecord {
+ userId: string;
+ credentialId: number | null;
+ rdpCredentialId: number | null;
+ vncCredentialId: number | null;
+ telnetCredentialId: number | null;
+ vaultProfileId: number | null;
+ authType: string;
+}
+export interface HostListAccessEntry {
+ hostId: number;
+ permissionLevel: string;
+ expiresAt: string | null;
+}
+export type HostListRow = HostResolutionHostRecord & {
+ ownerId: string;
+ isShared: boolean;
+ permissionLevel?: string;
+ expiresAt?: string | null;
+};
+
+export class HostResolutionRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async findHostById(
+ hostId: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
+ .limit(1);
+
+ return this.decryptOne("ssh_data", rows[0], userId);
+ }
+
+ async findHostByIdForUser(
+ hostId: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .limit(1);
+
+ return this.decryptOne("ssh_data", rows[0], userId);
+ }
+
+ async findHostUpdateState(
+ hostId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({
+ userId: hosts.userId,
+ credentialId: hosts.credentialId,
+ rdpCredentialId: hosts.rdpCredentialId,
+ vncCredentialId: hosts.vncCredentialId,
+ telnetCredentialId: hosts.telnetCredentialId,
+ vaultProfileId: hosts.vaultProfileId,
+ authType: hosts.authType,
+ })
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async findHostsByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(eq(hosts.userId, userId));
+
+ return this.decryptMany("ssh_data", rows, userId);
+ }
+
+ async listHostRowsForAccessList(
+ userId: string,
+ accessEntries: HostListAccessEntry[],
+ ): Promise {
+ const ownHostRows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(eq(hosts.userId, userId));
+
+ const sharedHostIds = Array.from(
+ new Set(accessEntries.map((access) => access.hostId)),
+ );
+ const sharedHostRows =
+ sharedHostIds.length > 0
+ ? await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(inArray(hosts.id, sharedHostIds))
+ : [];
+ const sharedHostsById = new Map(
+ sharedHostRows.map((host) => [host.id, host]),
+ );
+
+ return [
+ ...ownHostRows.map((host) => ({
+ ...host,
+ ownerId: host.userId,
+ isShared: false,
+ permissionLevel: undefined,
+ expiresAt: undefined,
+ })),
+ ...accessEntries.flatMap((access) => {
+ const host = sharedHostsById.get(access.hostId);
+ if (!host || host.userId === userId) {
+ return [];
+ }
+
+ return [
+ {
+ ...host,
+ ownerId: host.userId,
+ isShared: host.userId !== userId,
+ permissionLevel: access.permissionLevel,
+ expiresAt: access.expiresAt,
+ },
+ ];
+ }),
+ ];
+ }
+
+ async findHostOwnerId(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .select({ ownerId: hosts.userId })
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
+ .limit(1);
+
+ return rows[0]?.ownerId ?? null;
+ }
+
+ async isHostOwnedByUser(hostId: number, userId: string): Promise {
+ const rows = await this.context.drizzle
+ .select({ id: hosts.id })
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
+ .limit(1);
+
+ return rows.length > 0;
+ }
+
+ async listAllHosts(): Promise {
+ const rows = await this.context.drizzle.select().from(hosts);
+
+ return this.decryptManyByOwner("ssh_data", rows);
+ }
+
+ async listHostsWithTunnelConnections(): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(
+ and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
+ );
+
+ return this.decryptManyByOwner("ssh_data", rows);
+ }
+
+ async listHostsUsingCredentialForUser(
+ userId: string,
+ credentialId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(hosts)
+ .where(
+ and(eq(hosts.credentialId, credentialId), eq(hosts.userId, userId)),
+ );
+
+ return this.decryptMany("ssh_data", rows, userId);
+ }
+
+ async findHostKeyVerificationData(
+ hostId: number,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({
+ hostKeyFingerprint: hosts.hostKeyFingerprint,
+ hostKeyType: hosts.hostKeyType,
+ hostKeyAlgorithm: hosts.hostKeyAlgorithm,
+ hostKeyChangedCount: hosts.hostKeyChangedCount,
+ name: hosts.name,
+ })
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async storeHostKey(
+ hostId: number,
+ fingerprint: string,
+ keyType: string,
+ algorithm: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ await this.context.drizzle
+ .update(hosts)
+ .set({
+ hostKeyFingerprint: fingerprint,
+ hostKeyType: keyType,
+ hostKeyAlgorithm: algorithm,
+ hostKeyFirstSeen: now,
+ hostKeyLastVerified: now,
+ })
+ .where(eq(hosts.id, hostId));
+ await this.afterWrite();
+ }
+
+ async updateHostKey(
+ hostId: number,
+ fingerprint: string,
+ keyType: string,
+ algorithm: string,
+ currentChangeCount: number,
+ now = new Date().toISOString(),
+ ): Promise {
+ await this.context.drizzle
+ .update(hosts)
+ .set({
+ hostKeyFingerprint: fingerprint,
+ hostKeyType: keyType,
+ hostKeyAlgorithm: algorithm,
+ hostKeyLastVerified: now,
+ hostKeyChangedCount: currentChangeCount + 1,
+ })
+ .where(eq(hosts.id, hostId));
+ await this.afterWrite();
+ }
+
+ async touchHostKeyLastVerified(
+ hostId: number,
+ now = new Date().toISOString(),
+ ): Promise {
+ await this.context.drizzle
+ .update(hosts)
+ .set({ hostKeyLastVerified: now })
+ .where(eq(hosts.id, hostId));
+ await this.afterWrite();
+ }
+
+ async findCredentialByIdForUser(
+ credentialId: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, userId),
+ ),
+ )
+ .limit(1);
+
+ return this.decryptOne("ssh_credentials", rows[0], userId);
+ }
+
+ async findCredentialByIdForOwnerDecryptedAs(
+ credentialId: number,
+ ownerUserId: string,
+ decryptUserId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, ownerUserId),
+ ),
+ )
+ .limit(1);
+
+ return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
+ }
+
+ async findOverrideCredentialId(
+ hostId: number,
+ userId: string,
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({ overrideCredentialId: hostAccess.overrideCredentialId })
+ .from(hostAccess)
+ .where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)))
+ .limit(1);
+
+ return rows[0]?.overrideCredentialId ?? null;
+ }
+
+ private decryptOne>(
+ tableName: "ssh_data" | "ssh_credentials",
+ record: T | undefined,
+ userId: string,
+ ): T | null {
+ if (!record) return null;
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return null;
+ return DataCrypto.decryptRecord(tableName, record, userId, userDataKey);
+ }
+
+ private decryptMany>(
+ tableName: "ssh_data" | "ssh_credentials",
+ records: T[],
+ userId: string,
+ ): T[] {
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return [];
+ return records.map((record) =>
+ DataCrypto.decryptRecord(tableName, record, userId, userDataKey),
+ );
+ }
+
+ private decryptManyByOwner<
+ T extends Record & { userId: string },
+ >(tableName: "ssh_data" | "ssh_credentials", records: T[]): T[] {
+ return records.flatMap((record) => {
+ const userDataKey = DataCrypto.getUserDataKey(record.userId);
+ if (!userDataKey) return [];
+ return [
+ DataCrypto.decryptRecord(tableName, record, record.userId, userDataKey),
+ ];
+ });
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/network-topology-repository.ts b/src/backend/database/repositories/network-topology-repository.ts
new file mode 100644
index 00000000..fa8024fb
--- /dev/null
+++ b/src/backend/database/repositories/network-topology-repository.ts
@@ -0,0 +1,63 @@
+import { eq } from "drizzle-orm";
+import { networkTopology } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
+
+export class NetworkTopologyRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async findByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(networkTopology)
+ .where(eq(networkTopology.userId, userId))
+ .limit(1);
+
+ return rows[0] ?? null;
+ }
+
+ async upsertForUser(
+ userId: string,
+ topology: string,
+ updatedAt = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.findByUserId(userId);
+
+ if (existing) {
+ await this.context.drizzle
+ .update(networkTopology)
+ .set({ topology, updatedAt })
+ .where(eq(networkTopology.userId, userId));
+ await this.afterWrite();
+ return;
+ }
+
+ await this.context.drizzle.insert(networkTopology).values({
+ userId,
+ topology,
+ updatedAt,
+ });
+ await this.afterWrite();
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(networkTopology)
+ .where(eq(networkTopology.userId, userId))
+ .returning({ id: networkTopology.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/open-tab-repository.ts b/src/backend/database/repositories/open-tab-repository.ts
new file mode 100644
index 00000000..5cad21de
--- /dev/null
+++ b/src/backend/database/repositories/open-tab-repository.ts
@@ -0,0 +1,169 @@
+import { and, eq, gt } from "drizzle-orm";
+import { userOpenTabs } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
+export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
+export type OpenTabUpdate = Partial<
+ Pick
+>;
+
+export type OpenTabUpsertInput = Pick<
+ NewOpenTabRecord,
+ "id" | "tabType" | "label" | "tabOrder"
+> & {
+ hostId?: number | null;
+ backendSessionId?: string | null;
+};
+
+export class OpenTabRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async listRecentForUser(
+ userId: string,
+ updatedAfter: string,
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(userOpenTabs)
+ .where(
+ and(
+ eq(userOpenTabs.userId, userId),
+ gt(userOpenTabs.updatedAt, updatedAfter),
+ ),
+ )
+ .orderBy(userOpenTabs.tabOrder);
+ }
+
+ async upsertForUser(
+ userId: string,
+ input: OpenTabUpsertInput,
+ updatedAt = new Date().toISOString(),
+ ): Promise {
+ const existing = await this.findByIdForUser(userId, input.id);
+ if (existing) {
+ await this.context.drizzle
+ .update(userOpenTabs)
+ .set({
+ tabType: input.tabType,
+ hostId: input.hostId ?? null,
+ label: input.label,
+ tabOrder: input.tabOrder,
+ backendSessionId:
+ input.backendSessionId !== undefined
+ ? input.backendSessionId
+ : existing.backendSessionId,
+ updatedAt,
+ })
+ .where(
+ and(eq(userOpenTabs.id, input.id), eq(userOpenTabs.userId, userId)),
+ );
+ await this.afterWrite();
+ return;
+ }
+
+ await this.context.drizzle.insert(userOpenTabs).values({
+ id: input.id,
+ userId,
+ tabType: input.tabType,
+ hostId: input.hostId ?? null,
+ label: input.label,
+ tabOrder: input.tabOrder,
+ backendSessionId: input.backendSessionId ?? null,
+ updatedAt,
+ });
+ await this.afterWrite();
+ }
+
+ async replaceForUser(
+ userId: string,
+ tabs: OpenTabUpsertInput[],
+ updatedAt = new Date().toISOString(),
+ ): Promise {
+ await this.context.drizzle
+ .delete(userOpenTabs)
+ .where(eq(userOpenTabs.userId, userId));
+
+ if (tabs.length > 0) {
+ await this.context.drizzle.insert(userOpenTabs).values(
+ tabs.map((tab) => ({
+ id: tab.id,
+ userId,
+ tabType: tab.tabType,
+ hostId: tab.hostId ?? null,
+ label: tab.label,
+ tabOrder: tab.tabOrder,
+ backendSessionId: tab.backendSessionId ?? null,
+ updatedAt,
+ })),
+ );
+ }
+
+ await this.afterWrite();
+ }
+
+ async updateForUser(
+ userId: string,
+ id: string,
+ update: OpenTabUpdate,
+ updatedAt = new Date().toISOString(),
+ ): Promise {
+ const rows = await this.context.drizzle
+ .update(userOpenTabs)
+ .set({ ...update, updatedAt })
+ .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
+ .returning({ id: userOpenTabs.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length > 0;
+ }
+
+ async deleteForUser(userId: string, id: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(userOpenTabs)
+ .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
+ .returning({ id: userOpenTabs.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ async deleteByUserId(userId: string): Promise {
+ const rows = await this.context.drizzle
+ .delete(userOpenTabs)
+ .where(eq(userOpenTabs.userId, userId))
+ .returning({ id: userOpenTabs.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+
+ return rows.length;
+ }
+
+ private async findByIdForUser(
+ userId: string,
+ id: string,
+ ): Promise