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
-Termix Banner +Termix Banner

- Repo of the Day Achievement + Repo of the Day Achievement
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 - -
- -
- -[![YouTube](./repo-images/YouTube.png)](https://www.youtube.com/@TermixSSH/videos) - -Watch update overviews on YouTube - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
Termix Screenshot 15Termix Screenshot 16
- -Some videos and images may be out of date or may not perfectly showcase features. - -
- -
- -## 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     - AWS + Rack Genius
@@ -409,6 +351,66 @@ If you need help or want to request a feature with Termix, visit the [Issues](ht
+## Screenshots + +
+ +
+ +[![YouTube](./docs/repo-images/YouTube.png)](https://www.youtube.com/@TermixSSH/videos) + +Watch update overviews on YouTube + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Termix Screenshot 1Termix Screenshot 2
Termix Screenshot 3Termix Screenshot 4
Termix Screenshot 5Termix Screenshot 6
Termix Screenshot 7Termix Screenshot 8
Termix Screenshot 9Termix Screenshot 10
Termix Screenshot 11Termix Screenshot 12
Termix Screenshot 13Termix Screenshot 14
Termix Screenshot 15Termix Screenshot 16
+ +Some videos and images may be out of date or may not perfectly showcase features. + +
+ +
+ +## 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 @@ Donate

+

+ Donations this month +

+
Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير. -Monthly donation goal -
Termix Banner @@ -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). تابع التقدم وتبرع أدناه. -Monthly donation goal +[تبرع](https://donate.termix.site/) + +
+ +## الرعاة + +هل تريد إعلاناً مدفوعاً لدعم التطوير؟ راسلنا عبر البريد الإلكتروني [mail@termix.site](mailto:mail@termix.site). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## الدعم + +إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول.
@@ -356,50 +411,6 @@ Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ف
-## الرعاة - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## الدعم - -إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول. - -
- ## الترخيص موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف `LICENSE` لمزيد من المعلومات. diff --git a/readme/README-CN.md b/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 @@ Donate

+

+ Donations this month +

+
Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。 -Monthly donation goal -
Termix Banner @@ -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 支持等功能所需的时间。在下方追踪进度并进行捐赠。 -Monthly donation goal +[捐赠](https://donate.termix.site/) + +
+ +## 赞助商 + +有意通过付费展示位置支持开发吗?请发送邮件至 [mail@termix.site](mailto:mail@termix.site)。 + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 支持 + +如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。
@@ -356,50 +411,6 @@ Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://do
-## 赞助商 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## 支持 - -如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。 - -
- ## 许可证 根据 Apache License Version 2.0 发布。更多信息请参见 `LICENSE`。 diff --git a/readme/README-DE.md b/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 @@ Donate

+

+ Donations this month +

+
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. -Monthly donation goal -
Termix Banner @@ -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. -Monthly donation goal +[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). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Support - -Wenn Sie Hilfe benotigen oder eine Funktion fur Termix anfragen mochten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. Bitte beschreiben Sie Ihr Anliegen so detailliert wie moglich, vorzugsweise auf Englisch. Sie konnen auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings konnen die Antwortzeiten dort langer sein. - -
- ## Lizenz Verteilt unter der Apache License Version 2.0. Siehe `LICENSE` fur weitere Informationen. diff --git a/readme/README-ES.md b/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 @@ Donate

+

+ Donations this month +

+
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. -Monthly donation goal -
Termix Banner @@ -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. -Monthly donation goal +[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). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Soporte - -Si necesita ayuda o desea solicitar una funcion para Termix, visite la pagina de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesion y pulse `New Issue`. Por favor, sea lo mas detallado posible en su reporte, preferiblemente escrito en ingles. Tambien puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser mas largos. - -
- ## Licencia Distribuido bajo la Licencia Apache Version 2.0. Consulte `LICENSE` para mas informacion. diff --git a/readme/README-FR.md b/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 @@ Donate

+

+ Donations this month +

+
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. -Monthly donation goal -
Termix Banner @@ -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. -Monthly donation goal +[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). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Support - -Si vous avez besoin d'aide ou souhaitez demander une fonctionnalite pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez etre aussi detaille que possible dans votre issue, de preference redigee en anglais. Vous pouvez egalement rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de reponse peuvent etre plus longs. - -
- ## Licence Distribue sous la licence Apache Version 2.0. Consultez `LICENSE` pour plus d'informations. diff --git a/readme/README-HI.md b/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 @@ Donate

+

+ Donations this month +

+
Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)। -Monthly donation goal -
Termix Banner @@ -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 सपोर्ट जैसी सुविधाओं के निर्माण के लिए आवश्यक शोध और सीखने में लगने वाले समय को वित्त पोषित करने में भी मदद करते हैं। नीचे प्रगति देखें और दान करें। -Monthly donation goal +[दान करें](https://donate.termix.site/) + +
+ +## प्रायोजक + +विकास को समर्थन देने के लिए पेड प्लेसमेंट में रुचि है? [mail@termix.site](mailto:mail@termix.site) पर ईमेल करें। + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## सहायता + +यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है।
@@ -356,50 +411,6 @@ Termix मुफ़्त और ओपन सोर्स है। यदि
-## प्रायोजक - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## सहायता - -यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है। - -
- ## लाइसेंस Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए `LICENSE` देखें। diff --git a/readme/README-IT.md b/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 @@ Donate

+

+ Donazioni di questo mese +

+
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. -Monthly donation goal -
Termix Banner @@ -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 @@ -95,7 +97,7 @@ Gestisci i file direttamente sui server remoti con supporto per la visualizzazio @@ -128,8 +130,8 @@ Elenca i dispositivi della tua rete Tailscale per aggiungerli rapidamente come h @@ -137,7 +139,7 @@ Crea ruoli e condividi host tra utenti/ruoli. @@ -171,7 +173,7 @@ Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle conne @@ -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 - + @@ -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. -Monthly donation goal +[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). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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
**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.
**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.
-**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.
**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.
**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.
Windows x64/ia32Portable · MSI Installer · ChocolateyPortable · Installer MSI · Chocolatey
Linux x64/ia32
-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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Supporto - -Se hai bisogno di aiuto o vuoi richiedere una funzionalita per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. Per favore, sii il piu dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere piu lunghi. +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 @@ Donate

+

+ Donations this month +

+
Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。 -Monthly donation goal -
Termix Banner @@ -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サポートなどの機能を構築するために必要な調査と学習の時間を確保することにも役立ちます。以下で進捗を確認し、寄付できます。 -Monthly donation goal +[寄付する](https://donate.termix.site/) + +
+ +## スポンサー + +開発を支援するための有料掲載にご興味がありますか?[mail@termix.site](mailto:mail@termix.site)までメールをお送りください。 + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## サポート + +Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。
@@ -356,50 +411,6 @@ Termix は無料のオープンソースプロジェクトです。便利だと
-## スポンサー - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## サポート - -Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。 - -
- ## ライセンス Apache License Version 2.0のもとで配布されています。詳細は`LICENSE`をご覧ください。 diff --git a/readme/README-KO.md b/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 @@ Donate

+

+ Donations this month +

+
Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요. -Monthly donation goal -
Termix Banner @@ -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, 에이전트 지원과 같은 기능을 구축하는 데 필요한 사항을 연구하고 학습하는 시간에도 사용됩니다. 아래에서 진행 상황을 확인하고 후원할 수 있습니다. -Monthly donation goal +[후원하기](https://donate.termix.site/) + +
+ +## 스폰서 + +개발 지원을 위한 유료 광고에 관심이 있으신가요? [mail@termix.site](mailto:mail@termix.site)로 이메일을 보내주세요. + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 지원 + +Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다.
@@ -356,50 +411,6 @@ Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고
-## 스폰서 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## 지원 - -Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다. - -
- ## 라이선스 Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 `LICENSE`를 참조하세요. diff --git a/readme/README-PT.md b/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 @@ Donate

+

+ Donations this month +

+
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. -Monthly donation goal -
Termix Banner @@ -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. -Monthly donation goal +[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). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Suporte - -Se voce precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a pagina de [Issues](https://github.com/Termix-SSH/Support/issues), faca login e clique em `New Issue`. Por favor, seja o mais detalhado possivel no seu relato, preferencialmente escrito em ingles. Voce tambem pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porem, os tempos de resposta podem ser mais longos. - -
- ## Licenca Distribuido sob a Licenca Apache Versao 2.0. Consulte `LICENSE` para mais informacoes. diff --git a/readme/README-RU.md b/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 @@ Donate

+

+ Donations this month +

+
Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки. -Monthly donation goal -
Termix Banner @@ -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. Отслеживайте прогресс и делайте пожертвования ниже. -Monthly donation goal +[Пожертвовать](https://donate.termix.site/) + +
+ +## Спонсоры + +Заинтересованы в платном размещении для поддержки разработки? Напишите на [mail@termix.site](mailto:mail@termix.site). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## Поддержка + +Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше.
@@ -356,50 +411,6 @@ Termix — бесплатный проект с открытым исходны
-## Спонсоры - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Поддержка - -Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше. - -
- ## Лицензия Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле `LICENSE`. diff --git a/readme/README-TR.md b/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 @@ Donate

+

+ Donations this month +

+
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. -Monthly donation goal -
Termix Banner @@ -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. -Monthly donation goal +[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. + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Destek - -Termix ile ilgili yardima ihtiyaciniz varsa veya bir ozellik talep etmek istiyorsaniz, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasini ziyaret edin, giris yapin ve `New Issue` butonuna basin. Lutfen sorununuzu mumkun oldugunca ayrintili yazin, tercihen Ingilizce olarak. Ayrica [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katilabilir ve destek kanalini ziyaret edebilirsiniz, ancak yanit sureleri daha uzun olabilir. - -
- ## Lisans Apache Lisansi Surumu 2.0 altinda dagitilmaktadir. Daha fazla bilgi icin `LICENSE` dosyasina bakin. diff --git a/readme/README-VI.md b/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 @@ Donate

+

+ Donations this month +

+
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. -Monthly donation goal -
Termix Banner @@ -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. -Monthly donation goal +[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). + +
+ +
+ + + DigitalOcean + +    + + Crowdin + +    + + Blacksmith + +    + + Cloudflare + +    + + Tailscale + +    + + Akamai + +    + + AWS + +    + + Rack Genius + + +
+ +
+ +## 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 - -
- -
- - - DigitalOcean - -    - - Crowdin - -    - - Blacksmith - -    - - Cloudflare - -    - - Tailscale - -    - - Akamai - -    - - AWS - - -
- -
- -## Ho Tro - -Neu ban can tro giup hoac muon yeu cau tinh nang voi Termix, hay truy cap trang [Van De](https://github.com/Termix-SSH/Support/issues), dang nhap va nhan `New Issue`. Vui long mo ta van de cang chi tiet cang tot, uu tien viet bang tieng Anh. Ban cung co the tham gia may chu [Discord](https://discord.gg/jVQGdvHDrf) va truy cap kenh ho tro, tuy nhien thoi gian phan hoi co the lau hon. - -
- ## Giay Phep Duoc phan phoi theo Giay Phep Apache Phien Ban 2.0. Xem `LICENSE` de biet them thong tin. diff --git a/repo-images/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 = [ ``, - ` YouTube`, + ` YouTube`, ``, ].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 { + const rows = await this.context.drizzle + .select() + .from(userOpenTabs) + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/opkssh-token-repository.ts b/src/backend/database/repositories/opkssh-token-repository.ts new file mode 100644 index 00000000..0c15bddd --- /dev/null +++ b/src/backend/database/repositories/opkssh-token-repository.ts @@ -0,0 +1,125 @@ +import { and, eq } from "drizzle-orm"; +import { opksshTokens } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type OpksshTokenRecord = typeof opksshTokens.$inferSelect; + +export interface OpksshTokenUpsertInput { + userId: string; + hostId: number; + sshCert: string; + privateKey: string; + email?: string | null; + sub?: string | null; + issuer?: string | null; + audience?: string | null; + expiresAt: string; + createdAt?: string; +} + +export class OpksshTokenRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async upsert(input: OpksshTokenUpsertInput): Promise { + const createdAt = input.createdAt ?? new Date().toISOString(); + + await this.context.drizzle + .insert(opksshTokens) + .values({ + userId: input.userId, + hostId: input.hostId, + sshCert: input.sshCert, + privateKey: input.privateKey, + email: input.email, + sub: input.sub, + issuer: input.issuer, + audience: input.audience, + expiresAt: input.expiresAt, + }) + .onConflictDoUpdate({ + target: [opksshTokens.userId, opksshTokens.hostId], + set: { + sshCert: input.sshCert, + privateKey: input.privateKey, + email: input.email, + sub: input.sub, + issuer: input.issuer, + audience: input.audience, + expiresAt: input.expiresAt, + createdAt, + }, + }); + + await this.afterWrite(); + } + + async findByUserAndHost( + userId: string, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(opksshTokens) + .where( + and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async updateLastUsed( + userId: string, + hostId: number, + lastUsed = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .update(opksshTokens) + .set({ lastUsed }) + .where( + and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), + ) + .returning({ id: opksshTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserAndHost(userId: string, hostId: number): Promise { + const rows = await this.context.drizzle + .delete(opksshTokens) + .where( + and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), + ) + .returning({ id: opksshTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(opksshTokens) + .where(eq(opksshTokens.userId, userId)) + .returning({ id: opksshTokens.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/rbac-access-repository.ts b/src/backend/database/repositories/rbac-access-repository.ts new file mode 100644 index 00000000..92a2463e --- /dev/null +++ b/src/backend/database/repositories/rbac-access-repository.ts @@ -0,0 +1,721 @@ +import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm"; +import { + hostAccess, + hosts, + roles, + sharedHostSecrets, + snippetAccess, + snippets, + users, +} from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type RbacAccessTargetType = "user" | "role"; + +export interface RbacAccessListItem { + id: number; + targetType: RbacAccessTargetType; + userId: string | null; + roleId: number | null; + username: string | null; + roleName: string | null; + roleDisplayName: string | null; + grantedBy: string; + grantedByUsername: string | null; + permissionLevel: string; + expiresAt: string | null; + createdAt: string; +} + +export interface RbacSharedHost { + id: number; + name: string | null; + ip: string; + port: number; + username: string; + folder: string | null; + tags: string | null; + permissionLevel: string; + expiresAt: string | null; + grantedBy: string; + ownerUsername: string; +} + +export interface RbacSharedSnippet { + id: number; + name: string; + content: string; + description: string | null; + folder: string | null; + ownerUsername: string; + permissionLevel: string; + expiresAt: string | null; +} + +export interface RbacVisibleSharedSnippet extends RbacSharedSnippet { + userId: string; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet { + hostFilter: string | null; +} + +export interface RbacRoleHostAccessCredentialSource { + hostAccessId: number; + credentialId: number | null; + rdpCredentialId: number | null; + vncCredentialId: number | null; + telnetCredentialId: number | null; + hostId: number; + hostOwnerId: string; +} + +export interface RbacVisibleHostAccessEntry { + hostId: number; + permissionLevel: string; + expiresAt: string | null; +} + +export type RbacAccessTarget = + | { targetType: "user"; targetUserId: string } + | { targetType: "role"; targetRoleId: number }; + +export type UpsertHostAccessInput = RbacAccessTarget & { + hostId: number; + grantedBy: string; + permissionLevel: string; + expiresAt: string | null; +}; + +export type UpsertSnippetAccessInput = RbacAccessTarget & { + snippetId: number; + grantedBy: string; + expiresAt: string | null; +}; + +type RawAccessListItem = Omit; + +function toAccessListItem(access: RawAccessListItem): RbacAccessListItem { + return { + ...access, + targetType: access.userId ? "user" : "role", + }; +} + +export class RbacAccessRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listHostAccess(hostId: number): Promise { + const rows = await this.context.drizzle + .select({ + id: hostAccess.id, + userId: hostAccess.userId, + roleId: hostAccess.roleId, + username: users.username, + roleName: roles.name, + roleDisplayName: roles.displayName, + grantedBy: hostAccess.grantedBy, + grantedByUsername: sql< + string | null + >`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + createdAt: hostAccess.createdAt, + }) + .from(hostAccess) + .leftJoin(users, eq(hostAccess.userId, users.id)) + .leftJoin(roles, eq(hostAccess.roleId, roles.id)) + .where(eq(hostAccess.hostId, hostId)) + .orderBy(desc(hostAccess.createdAt)); + + return rows.map(toAccessListItem); + } + + async upsertHostAccess(input: UpsertHostAccessInput): Promise<{ + id: number; + created: boolean; + }> { + const existing = await this.findHostAccess(input.hostId, input); + + if (existing) { + await this.context.drizzle + .update(hostAccess) + .set({ + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }) + .where(eq(hostAccess.id, existing.id)); + + await this.afterWrite(); + return { id: existing.id, created: false }; + } + + const result = await this.context.drizzle.insert(hostAccess).values({ + hostId: input.hostId, + userId: input.targetType === "user" ? input.targetUserId : null, + roleId: input.targetType === "role" ? input.targetRoleId : null, + grantedBy: input.grantedBy, + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }); + + await this.afterWrite(); + return { id: Number(result.lastInsertRowid), created: true }; + } + + async revokeHostAccess(accessId: number, hostId: number): Promise { + await this.context.drizzle + .delete(hostAccess) + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))); + await this.afterWrite(); + } + + async deleteHostAccessForHost(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; + } + + async deleteHostAccessForHosts(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(hostAccess) + .where(inArray(hostAccess.hostId, hostIds)) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteHostAccessForUserReferences(userId: string): Promise { + const directRows = await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.userId, userId)) + .returning({ id: hostAccess.id }); + + const grantedRows = await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.grantedBy, userId)) + .returning({ id: hostAccess.id }); + + const deletedCount = directRows.length + grantedRows.length; + if (deletedCount > 0) { + await this.afterWrite(); + } + + return deletedCount; + } + + async findDirectHostAccess( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async updateHostAccessOverrideCredential( + accessId: number, + credentialId: number | null, + ): Promise { + await this.context.drizzle + .update(hostAccess) + .set({ overrideCredentialId: credentialId }) + .where(eq(hostAccess.id, accessId)); + await this.afterWrite(); + } + + async listSnippetAccess(snippetId: number): Promise { + const rows = await this.context.drizzle + .select({ + id: snippetAccess.id, + userId: snippetAccess.userId, + roleId: snippetAccess.roleId, + username: users.username, + roleName: roles.name, + roleDisplayName: roles.displayName, + grantedBy: snippetAccess.grantedBy, + grantedByUsername: sql< + string | null + >`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + createdAt: snippetAccess.createdAt, + }) + .from(snippetAccess) + .leftJoin(users, eq(snippetAccess.userId, users.id)) + .leftJoin(roles, eq(snippetAccess.roleId, roles.id)) + .where(eq(snippetAccess.snippetId, snippetId)) + .orderBy(desc(snippetAccess.createdAt)); + + return rows.map(toAccessListItem); + } + + async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{ + id: number; + created: boolean; + }> { + const existing = await this.findSnippetAccess(input.snippetId, input); + + if (existing) { + await this.context.drizzle + .update(snippetAccess) + .set({ expiresAt: input.expiresAt }) + .where(eq(snippetAccess.id, existing.id)); + + await this.afterWrite(); + return { id: existing.id, created: false }; + } + + const result = await this.context.drizzle.insert(snippetAccess).values({ + snippetId: input.snippetId, + userId: input.targetType === "user" ? input.targetUserId : null, + roleId: input.targetType === "role" ? input.targetRoleId : null, + grantedBy: input.grantedBy, + permissionLevel: "view", + expiresAt: input.expiresAt, + }); + + await this.afterWrite(); + return { id: Number(result.lastInsertRowid), created: true }; + } + + async revokeSnippetAccess( + accessId: number, + snippetId: number, + ): Promise { + await this.context.drizzle + .delete(snippetAccess) + .where( + and( + eq(snippetAccess.id, accessId), + eq(snippetAccess.snippetId, snippetId), + ), + ); + await this.afterWrite(); + } + + async listSharedHosts( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select({ + id: hosts.id, + name: hosts.name, + ip: hosts.ip, + port: hosts.port, + username: hosts.username, + folder: hosts.folder, + tags: hosts.tags, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + grantedBy: hostAccess.grantedBy, + ownerUsername: users.username, + }) + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .innerJoin(users, eq(hosts.userId, users.id)) + .where( + and( + this.userOrRoleHostAccessFilter(userId, roleIds), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ) + .orderBy(desc(hostAccess.createdAt)); + } + + async listVisibleHostAccessEntries( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select({ + hostId: hostAccess.hostId, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + }) + .from(hostAccess) + .where( + and( + this.userOrRoleHostAccessFilter(userId, roleIds), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ) + .orderBy(desc(hostAccess.createdAt)); + } + + async listSharedSnippets( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const directShared = await this.context.drizzle + .select({ + id: snippets.id, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + eq(snippetAccess.userId, userId), + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + ), + ); + + if (roleIds.length === 0) { + return directShared; + } + + const directIds = new Set(directShared.map((snippet) => snippet.id)); + const roleShared = await this.context.drizzle + .select({ + id: snippets.id, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + inArray(snippetAccess.roleId, roleIds), + ), + ); + + return [ + ...directShared, + ...roleShared.filter((snippet) => !directIds.has(snippet.id)), + ]; + } + + async listVisibleSharedSnippets( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select({ + id: snippets.id, + userId: snippets.userId, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + order: snippets.order, + createdAt: snippets.createdAt, + updatedAt: snippets.updatedAt, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + this.userOrRoleSnippetAccessFilter(userId, roleIds), + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + ), + ); + } + + async findAccessibleSharedSnippet( + snippetId: number, + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select({ + id: snippets.id, + userId: snippets.userId, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + order: snippets.order, + createdAt: snippets.createdAt, + updatedAt: snippets.updatedAt, + hostFilter: snippets.hostFilter, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + eq(snippetAccess.snippetId, snippetId), + this.userOrRoleSnippetAccessFilter(userId, roleIds), + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async deleteExpiredHostAccess( + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .delete(hostAccess) + .where( + and( + sql`${hostAccess.expiresAt} IS NOT NULL`, + sql`${hostAccess.expiresAt} <= ${now}`, + ), + ) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async findActiveHostAccess( + hostId: number, + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + this.userOrRoleHostAccessFilter(userId, roleIds), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async touchHostAccess( + accessId: number, + lastAccessedAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(hostAccess) + .set({ lastAccessedAt }) + .where(eq(hostAccess.id, accessId)); + await this.afterWrite(); + } + + async listRoleHostAccessCredentialSources( + roleId: number, + ): Promise { + return this.context.drizzle + .select({ + hostAccessId: hostAccess.id, + credentialId: hosts.credentialId, + rdpCredentialId: hosts.rdpCredentialId, + vncCredentialId: hosts.vncCredentialId, + telnetCredentialId: hosts.telnetCredentialId, + hostId: hosts.id, + hostOwnerId: hosts.userId, + }) + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .where(eq(hostAccess.roleId, roleId)); + } + + async findSharedSecretForHostUserProtocol( + hostId: number, + userId: string, + protocol: string, + ): Promise { + const rows = await this.context.drizzle + .select({ + secret: sharedHostSecrets, + }) + .from(sharedHostSecrets) + .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(sharedHostSecrets.targetUserId, userId), + eq(sharedHostSecrets.protocol, protocol), + ), + ) + .limit(1); + + return rows[0]?.secret ?? null; + } + + async listActiveHostAccessGrants( + hostId: number, + now = new Date().toISOString(), + ): Promise<(typeof hostAccess.$inferSelect)[]> { + return this.context.drizzle + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ); + } + + async findHostAccessById( + accessId: number, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) + .limit(1); + + return rows[0] ?? null; + } + + async updateHostAccessGrant( + accessId: number, + hostId: number, + update: { permissionLevel?: string; expiresAt?: string | null }, + ): Promise { + const rows = await this.context.drizzle + .update(hostAccess) + .set(update) + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async findHostAccessOwnerId(hostAccessId: number): Promise { + const rows = await this.context.drizzle + .select({ ownerId: hosts.userId }) + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .where(eq(hostAccess.id, hostAccessId)) + .limit(1); + + return rows[0]?.ownerId ?? null; + } + + private userOrRoleHostAccessFilter(userId: string, roleIds: number[]) { + if (roleIds.length === 0) { + return eq(hostAccess.userId, userId); + } + + return or( + eq(hostAccess.userId, userId), + inArray(hostAccess.roleId, roleIds), + ); + } + + private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) { + if (roleIds.length === 0) { + return eq(snippetAccess.userId, userId); + } + + return or( + eq(snippetAccess.userId, userId), + inArray(snippetAccess.roleId, roleIds), + ); + } + + private async findHostAccess(hostId: number, target: RbacAccessTarget) { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + target.targetType === "user" + ? eq(hostAccess.userId, target.targetUserId) + : eq(hostAccess.roleId, target.targetRoleId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) { + const rows = await this.context.drizzle + .select() + .from(snippetAccess) + .where( + and( + eq(snippetAccess.snippetId, snippetId), + target.targetType === "user" + ? eq(snippetAccess.userId, target.targetUserId) + : eq(snippetAccess.roleId, target.targetRoleId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/recent-activity-repository.ts b/src/backend/database/repositories/recent-activity-repository.ts new file mode 100644 index 00000000..bd7979fc --- /dev/null +++ b/src/backend/database/repositories/recent-activity-repository.ts @@ -0,0 +1,112 @@ +import { desc, eq, inArray } from "drizzle-orm"; +import { recentActivity } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type RecentActivityRecord = typeof recentActivity.$inferSelect; +export type NewRecentActivityRecord = typeof recentActivity.$inferInsert; + +export class RecentActivityRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId( + userId: string, + limit: number, + ): Promise { + return this.context.drizzle + .select() + .from(recentActivity) + .where(eq(recentActivity.userId, userId)) + .orderBy(desc(recentActivity.timestamp)) + .limit(limit); + } + + async create( + activity: NewRecentActivityRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(recentActivity) + .values(activity) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async trimUserActivity(userId: string, keepCount: number): Promise { + const rows = await this.context.drizzle + .select({ id: recentActivity.id }) + .from(recentActivity) + .where(eq(recentActivity.userId, userId)) + .orderBy(desc(recentActivity.timestamp)); + + const idsToDelete = rows + .slice(keepCount) + .map((row) => row.id) + .filter((id) => typeof id === "number"); + + if (idsToDelete.length === 0) { + return 0; + } + + const deletedRows = await this.context.drizzle + .delete(recentActivity) + .where(inArray(recentActivity.id, idsToDelete)) + .returning({ id: recentActivity.id }); + + if (deletedRows.length > 0) { + await this.afterWrite(); + } + + return deletedRows.length; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(recentActivity) + .where(eq(recentActivity.userId, userId)) + .returning({ id: recentActivity.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(recentActivity) + .where(eq(recentActivity.hostId, hostId)) + .returning({ id: recentActivity.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(recentActivity) + .where(inArray(recentActivity.hostId, hostIds)) + .returning({ id: recentActivity.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/role-repository.ts b/src/backend/database/repositories/role-repository.ts new file mode 100644 index 00000000..47f53d76 --- /dev/null +++ b/src/backend/database/repositories/role-repository.ts @@ -0,0 +1,276 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { hostAccess, roles, userRoles } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type RoleRecord = typeof roles.$inferSelect; +export type NewRoleRecord = typeof roles.$inferInsert; +export type RoleUpdate = Pick< + Partial, + "displayName" | "description" | "updatedAt" +>; + +export type UserRoleWithRole = { + id: number; + roleId: number; + roleName: string; + roleDisplayName: string; + description: string | null; + isSystem: boolean; + grantedAt: string; +}; + +export type UserRolePermissionRecord = { + permissions: string | null; +}; + +export type UserRoleNameSwitchResult = { + added: boolean; + removed: boolean; +}; + +export class RoleRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listRoles(): Promise { + return this.context.drizzle + .select() + .from(roles) + .orderBy(roles.isSystem, roles.name); + } + + async findRoleById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(roles) + .where(eq(roles.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findRoleByName(name: string): Promise { + const rows = await this.context.drizzle + .select() + .from(roles) + .where(eq(roles.name, name)) + .limit(1); + + return rows[0] ?? null; + } + + async createRole(role: NewRoleRecord): Promise { + const result = await this.context.drizzle.insert(roles).values(role); + await this.afterWrite(); + return Number(result.lastInsertRowid); + } + + async updateRole(id: number, update: RoleUpdate): Promise { + const rows = await this.context.drizzle + .update(roles) + .set(update) + .where(eq(roles.id, id)) + .returning({ id: roles.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> { + const deletedUserRoles = await this.context.drizzle + .delete(userRoles) + .where(eq(userRoles.roleId, id)) + .returning({ userId: userRoles.userId }); + + await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.roleId, id)); + + await this.context.drizzle.delete(roles).where(eq(roles.id, id)); + await this.afterWrite(); + + return { + deletedUserIds: deletedUserRoles.map((row) => row.userId), + }; + } + + async findUserRole( + userId: string, + roleId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(userRoles) + .where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))) + .limit(1); + + return rows[0] ?? null; + } + + async assignRoleToUser(input: { + userId: string; + roleId: number; + grantedBy: string; + }): Promise { + await this.context.drizzle.insert(userRoles).values(input); + await this.afterWrite(); + } + + async assignRoleNameToUser(input: { + userId: string; + roleName: string; + grantedBy: string; + }): Promise { + const role = await this.findRoleByName(input.roleName); + if (!role) { + return false; + } + + await this.context.drizzle.insert(userRoles).values({ + userId: input.userId, + roleId: role.id, + grantedBy: input.grantedBy, + }); + await this.afterWrite(); + return true; + } + + async switchUserRoleName(input: { + userId: string; + addRoleName: string; + removeRoleName: string; + grantedBy: string; + }): Promise { + const [addRole, removeRole] = await Promise.all([ + this.findRoleByName(input.addRoleName), + this.findRoleByName(input.removeRoleName), + ]); + + let added = false; + let removed = false; + + if (addRole) { + await this.context.drizzle + .delete(userRoles) + .where( + and( + eq(userRoles.userId, input.userId), + eq(userRoles.roleId, addRole.id), + ), + ); + await this.context.drizzle.insert(userRoles).values({ + userId: input.userId, + roleId: addRole.id, + grantedBy: input.grantedBy, + }); + added = true; + } + + if (removeRole) { + const rows = await this.context.drizzle + .delete(userRoles) + .where( + and( + eq(userRoles.userId, input.userId), + eq(userRoles.roleId, removeRole.id), + ), + ) + .returning({ id: userRoles.id }); + removed = rows.length > 0; + } + + if (added || removed) { + await this.afterWrite(); + } + + return { added, removed }; + } + + async removeRoleFromUser(userId: string, roleId: number): Promise { + await this.context.drizzle + .delete(userRoles) + .where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))); + await this.afterWrite(); + } + + async removeAllRolesFromUser(userId: string): Promise { + const rows = await this.context.drizzle + .delete(userRoles) + .where(eq(userRoles.userId, userId)) + .returning({ id: userRoles.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async listUserRoleIds(userId: string): Promise { + const rows = await this.context.drizzle + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + + return rows.map((row) => row.roleId); + } + + async listRoleUserIds(roleId: number): Promise { + const rows = await this.context.drizzle + .select({ userId: userRoles.userId }) + .from(userRoles) + .where(eq(userRoles.roleId, roleId)); + + return rows.map((row) => row.userId); + } + + async listUserRolePermissions( + userId: string, + ): Promise { + return this.context.drizzle + .select({ permissions: roles.permissions }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + } + + async userHasAnyRoleName( + userId: string, + roleNames: string[], + ): Promise { + if (roleNames.length === 0) { + return false; + } + + const rows = await this.context.drizzle + .select({ roleName: roles.name }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(and(eq(userRoles.userId, userId), inArray(roles.name, roleNames))) + .limit(1); + + return rows.length > 0; + } + + async listUserRoles(userId: string): Promise { + return this.context.drizzle + .select({ + id: userRoles.id, + roleId: roles.id, + roleName: roles.name, + roleDisplayName: roles.displayName, + description: roles.description, + isSystem: roles.isSystem, + grantedAt: userRoles.grantedAt, + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts new file mode 100644 index 00000000..678a24a1 --- /dev/null +++ b/src/backend/database/repositories/session-recording-repository.ts @@ -0,0 +1,241 @@ +import { and, desc, eq, inArray, lt } from "drizzle-orm"; +import { hosts, sessionRecordings } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect; + +export interface SessionRecordingCreateInput { + hostId: number; + userId: string; + startedAt: string; + endedAt?: string | null; + duration?: number | null; + commands?: string | null; + dangerousActions?: string | null; + recordingPath?: string | null; + protocol?: string | null; + format?: string | null; + terminatedByOwner?: boolean | null; + terminationReason?: string | null; +} + +export interface SessionRecordingListRecord { + id: number; + hostId: number; + userId: string; + startedAt: string; + endedAt: string | null; + duration: number | null; + recordingPath: string | null; + protocol: string; + format: string | null; + hostName: string | null; + hostIp: string | null; +} + +export interface SessionRecordingPathRecord { + id: number; + recordingPath: string | null; +} + +export class SessionRecordingRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create( + input: SessionRecordingCreateInput, + ): Promise { + const [created] = await this.context.drizzle + .insert(sessionRecordings) + .values(input) + .returning(); + + await this.afterWrite(); + return created; + } + + async updateEnded( + id: number, + input: { endedAt: string; duration: number | null }, + ): Promise { + await this.context.drizzle + .update(sessionRecordings) + .set(input) + .where(eq(sessionRecordings.id, id)); + await this.afterWrite(); + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionRecordings) + .where(eq(sessionRecordings.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findPathById(id: number): Promise< + | (SessionRecordingPathRecord & { + userId: string; + format?: string | null; + }) + | null + > { + const rows = await this.context.drizzle + .select({ + id: sessionRecordings.id, + recordingPath: sessionRecordings.recordingPath, + userId: sessionRecordings.userId, + format: sessionRecordings.format, + }) + .from(sessionRecordings) + .where(eq(sessionRecordings.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async listByUserIdWithHost( + userId: string, + ): Promise { + return this.context.drizzle + .select({ + id: sessionRecordings.id, + hostId: sessionRecordings.hostId, + userId: sessionRecordings.userId, + startedAt: sessionRecordings.startedAt, + endedAt: sessionRecordings.endedAt, + duration: sessionRecordings.duration, + recordingPath: sessionRecordings.recordingPath, + protocol: sessionRecordings.protocol, + format: sessionRecordings.format, + hostName: hosts.name, + hostIp: hosts.ip, + }) + .from(sessionRecordings) + .leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id)) + .where(eq(sessionRecordings.userId, userId)) + .orderBy(desc(sessionRecordings.startedAt)); + } + + async findByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionRecordings) + .where( + and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async findPathByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select({ + id: sessionRecordings.id, + recordingPath: sessionRecordings.recordingPath, + }) + .from(sessionRecordings) + .where( + and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async listPathsOlderThan( + cutoff: string, + ): Promise { + return this.context.drizzle + .select({ + id: sessionRecordings.id, + recordingPath: sessionRecordings.recordingPath, + }) + .from(sessionRecordings) + .where(lt(sessionRecordings.startedAt, cutoff)); + } + + async deleteById(id: number): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(eq(sessionRecordings.id, id)) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where( + and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), + ) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(eq(sessionRecordings.userId, userId)) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(eq(sessionRecordings.hostId, hostId)) + .returning({ id: sessionRecordings.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(sessionRecordings) + .where(inArray(sessionRecordings.hostId, hostIds)) + .returning({ id: sessionRecordings.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/session-repository.ts b/src/backend/database/repositories/session-repository.ts new file mode 100644 index 00000000..ff83c46e --- /dev/null +++ b/src/backend/database/repositories/session-repository.ts @@ -0,0 +1,114 @@ +import { and, eq, lte, ne } from "drizzle-orm"; +import { sessions } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SessionRecord = typeof sessions.$inferSelect; +export type NewSessionRecord = typeof sessions.$inferInsert; + +export class SessionRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(session: NewSessionRecord): Promise { + const rows = await this.context.drizzle + .insert(sessions) + .values(session) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async listAll(): Promise { + return this.context.drizzle.select().from(sessions); + } + + async findById(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(sessions) + .where(eq(sessions.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); + } + + async listExpired(now = new Date()): Promise { + return this.context.drizzle + .select() + .from(sessions) + .where(lte(sessions.expiresAt, now.toISOString())); + } + + async touch( + id: string, + lastActiveAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(sessions) + .set({ lastActiveAt }) + .where(eq(sessions.id, id)); + await this.afterWrite(); + } + + async updateToken( + id: string, + jwtToken: string, + lastActiveAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(sessions) + .set({ jwtToken, lastActiveAt }) + .where(eq(sessions.id, id)); + await this.afterWrite(); + } + + async revoke(id: string): Promise { + const rows = await this.context.drizzle + .delete(sessions) + .where(eq(sessions.id, id)) + .returning({ id: sessions.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async revokeAllForUser( + userId: string, + exceptSessionId?: string, + ): Promise { + const where = exceptSessionId + ? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId)) + : eq(sessions.userId, userId); + + const rows = await this.context.drizzle + .delete(sessions) + .where(where) + .returning({ id: sessions.id }); + + await this.afterWrite(); + return rows.length; + } + + async deleteExpired(now = new Date()): Promise { + const rows = await this.context.drizzle + .delete(sessions) + .where(lte(sessions.expiresAt, now.toISOString())) + .returning({ id: sessions.id }); + + await this.afterWrite(); + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/settings-repository.ts b/src/backend/database/repositories/settings-repository.ts new file mode 100644 index 00000000..3b69fbbc --- /dev/null +++ b/src/backend/database/repositories/settings-repository.ts @@ -0,0 +1,69 @@ +import { eq, like } from "drizzle-orm"; +import { settings } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export class SettingsRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async get(key: string): Promise { + const rows = await this.context.drizzle + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, key)) + .limit(1); + + return rows[0]?.value ?? null; + } + + async listAll(): Promise> { + return this.context.drizzle + .select({ key: settings.key, value: settings.value }) + .from(settings); + } + + async getBoolean(key: string, fallback = false): Promise { + const value = await this.get(key); + if (value === null) return fallback; + return value === "true" || value === "1"; + } + + async set(key: string, value: string): Promise { + const existing = await this.get(key); + if (existing === null) { + await this.context.drizzle.insert(settings).values({ key, value }); + await this.afterWrite(); + return; + } + + await this.context.drizzle + .update(settings) + .set({ value }) + .where(eq(settings.key, key)); + await this.afterWrite(); + } + + async upsert(key: string, value: string): Promise { + await this.set(key, value); + } + + async delete(key: string): Promise { + await this.context.drizzle.delete(settings).where(eq(settings.key, key)); + await this.afterWrite(); + } + + async deleteLike(pattern: string): Promise { + const rows = await this.context.drizzle + .delete(settings) + .where(like(settings.key, pattern)) + .returning({ key: settings.key }); + await this.afterWrite(); + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/shared-host-secrets-repository.ts b/src/backend/database/repositories/shared-host-secrets-repository.ts new file mode 100644 index 00000000..23e2a86d --- /dev/null +++ b/src/backend/database/repositories/shared-host-secrets-repository.ts @@ -0,0 +1,201 @@ +import { and, eq, inArray, or } from "drizzle-orm"; +import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect; +export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert; + +export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet"; + +export class SharedHostSecretsRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async upsert(record: NewSharedHostSecretRecord): Promise { + const existing = await this.context.drizzle + .select({ id: sharedHostSecrets.id }) + .from(sharedHostSecrets) + .where( + and( + eq(sharedHostSecrets.hostAccessId, record.hostAccessId), + eq(sharedHostSecrets.targetUserId, record.targetUserId), + eq(sharedHostSecrets.protocol, record.protocol ?? "ssh"), + ), + ) + .limit(1); + + if (existing.length > 0) { + await this.context.drizzle + .update(sharedHostSecrets) + .set({ ...record, updatedAt: new Date().toISOString() }) + .where(eq(sharedHostSecrets.id, existing[0].id)); + } else { + await this.context.drizzle.insert(sharedHostSecrets).values(record); + } + + await this.afterWrite(); + } + + async findForHostUserProtocol( + hostId: number, + targetUserId: string, + protocol: ShareProtocol, + ): Promise { + const rows = await this.context.drizzle + .select({ secret: sharedHostSecrets }) + .from(sharedHostSecrets) + .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(sharedHostSecrets.targetUserId, targetUserId), + eq(sharedHostSecrets.protocol, protocol), + ), + ) + .limit(1); + + return rows[0]?.secret ?? null; + } + + async existsForHostAccessAndTargetUser( + hostAccessId: number, + targetUserId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: sharedHostSecrets.id }) + .from(sharedHostSecrets) + .where( + and( + eq(sharedHostSecrets.hostAccessId, hostAccessId), + eq(sharedHostSecrets.targetUserId, targetUserId), + ), + ) + .limit(1); + + return rows.length > 0; + } + + async deleteForHostAccessAndTarget( + hostAccessId: number, + targetUserId: string, + keepProtocols: ShareProtocol[] = [], + ): Promise { + const rows = await this.context.drizzle + .select({ + id: sharedHostSecrets.id, + protocol: sharedHostSecrets.protocol, + }) + .from(sharedHostSecrets) + .where( + and( + eq(sharedHostSecrets.hostAccessId, hostAccessId), + eq(sharedHostSecrets.targetUserId, targetUserId), + ), + ); + + const staleIds = rows + .filter((row) => !keepProtocols.includes(row.protocol as ShareProtocol)) + .map((row) => row.id); + + if (staleIds.length > 0) { + await this.context.drizzle + .delete(sharedHostSecrets) + .where(inArray(sharedHostSecrets.id, staleIds)); + await this.afterWrite(); + } + } + + async deleteByHostAccessId(hostAccessId: number): Promise { + const rows = await this.context.drizzle + .delete(sharedHostSecrets) + .where(eq(sharedHostSecrets.hostAccessId, hostAccessId)) + .returning({ id: sharedHostSecrets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteForRoleMember( + roleId: number, + targetUserId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: sharedHostSecrets.id }) + .from(sharedHostSecrets) + .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) + .where( + and( + eq(hostAccess.roleId, roleId), + eq(sharedHostSecrets.targetUserId, targetUserId), + ), + ); + + if (rows.length === 0) return 0; + + await this.context.drizzle.delete(sharedHostSecrets).where( + inArray( + sharedHostSecrets.id, + rows.map((row) => row.id), + ), + ); + await this.afterWrite(); + return rows.length; + } + + async deleteByOriginalCredentialId(credentialId: number): Promise { + const rows = await this.context.drizzle + .delete(sharedHostSecrets) + .where(eq(sharedHostSecrets.originalCredentialId, credentialId)) + .returning({ id: sharedHostSecrets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByTargetUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sharedHostSecrets) + .where(eq(sharedHostSecrets.targetUserId, userId)) + .returning({ id: sharedHostSecrets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async findHostIdsReferencingCredential( + ownerId: string, + credentialId: number, + ): Promise { + const rows = await this.context.drizzle + .select({ id: hosts.id }) + .from(hosts) + .where( + and( + eq(hosts.userId, ownerId), + or( + eq(hosts.credentialId, credentialId), + eq(hosts.rdpCredentialId, credentialId), + eq(hosts.vncCredentialId, credentialId), + eq(hosts.telnetCredentialId, credentialId), + ), + ), + ); + + return rows.map((row) => row.id); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts new file mode 100644 index 00000000..5196ff38 --- /dev/null +++ b/src/backend/database/repositories/snippet-repository.ts @@ -0,0 +1,540 @@ +import { and, asc, eq, sql } from "drizzle-orm"; +import { snippetFolders, snippets } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SnippetRecord = typeof snippets.$inferSelect; +export type SnippetFolderRecord = typeof snippetFolders.$inferSelect; +export interface RenameSnippetFolderResult { + status: "renamed" | "missing" | "conflict"; +} +export interface SnippetReorderUpdate { + id: number; + order: number; + folder?: string; +} +export interface NewSnippetInput { + name: string; + content: string; + description?: string | null; + folder?: string | null; + order?: number | null; + hostFilter?: unknown; +} +export interface SnippetUpdateInput { + name?: string; + content?: string; + description?: string | null; + folder?: string | null; + order?: number; + hostFilter?: unknown; +} +export interface UpdateSnippetResult { + existing: SnippetRecord; + updated: SnippetRecord; +} +export interface SnippetBulkImportResult { + snippetsImported: number; + snippetsSkipped: number; + snippetsUpdated: number; + foldersImported: number; + foldersSkipped: number; + failed: number; + errors: string[]; +} +interface BulkImportSnippetInput { + name?: unknown; + content?: unknown; + description?: string | null; + folder?: string | null; + order?: unknown; + hostFilter?: string | null; +} +interface BulkImportFolderInput { + name?: unknown; + color?: string | null; + icon?: string | null; +} + +export class SnippetRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findOwnedById( + userId: string, + snippetId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async listFolders(userId: string): Promise { + return this.context.drizzle + .select() + .from(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .orderBy(asc(snippetFolders.name)); + } + + async listSnippetsForExport(userId: string): Promise { + return this.context.drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy(asc(snippets.folder), asc(snippets.order)); + } + + async listFoldersForExport(userId: string): Promise { + return this.listFolders(userId); + } + + async listOwnedSnippets(userId: string): Promise { + return this.context.drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy( + sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, + asc(snippets.folder), + asc(snippets.order), + sql`${snippets.updatedAt} DESC`, + ); + } + + async reorderSnippets( + userId: string, + updates: SnippetReorderUpdate[], + ): Promise { + for (const update of updates) { + const { id, order, folder } = update; + + if (!id || order === undefined) { + continue; + } + + const updateFields: Partial<{ + order: number; + folder: string | null; + }> = { + order, + }; + + if (folder !== undefined) { + updateFields.folder = folder?.trim() || null; + } + + await this.context.drizzle + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); + } + + await this.afterWrite(); + } + + async createSnippet( + userId: string, + input: NewSnippetInput, + ): Promise { + const folderValue = input.folder?.trim() || ""; + const order = + input.order === undefined || input.order === null + ? await this.nextOrderForFolder(userId, folderValue) + : input.order; + + const rows = await this.context.drizzle + .insert(snippets) + .values({ + userId, + name: input.name.trim(), + content: input.content.trim(), + description: input.description?.trim() || null, + folder: input.folder?.trim() || null, + order, + hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, + }) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateSnippet( + userId: string, + snippetId: number, + input: SnippetUpdateInput, + ): Promise { + const existing = await this.findOwnedById(userId, snippetId); + if (!existing) return null; + + const updateFields: Partial<{ + updatedAt: ReturnType; + name: string; + content: string; + description: string | null; + folder: string | null; + order: number; + hostFilter: string | null; + }> = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (input.name !== undefined) updateFields.name = input.name.trim(); + if (input.content !== undefined) + updateFields.content = input.content.trim(); + if (input.description !== undefined) + updateFields.description = input.description?.trim() || null; + if (input.folder !== undefined) + updateFields.folder = input.folder?.trim() || null; + if (input.order !== undefined) updateFields.order = input.order; + if (input.hostFilter !== undefined) + updateFields.hostFilter = input.hostFilter + ? JSON.stringify(input.hostFilter) + : null; + + const rows = await this.context.drizzle + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) + .returning(); + + await this.afterWrite(); + return { existing, updated: rows[0] }; + } + + async deleteSnippet( + userId: string, + snippetId: number, + ): Promise { + const existing = await this.findOwnedById(userId, snippetId); + if (!existing) return null; + + await this.context.drizzle + .delete(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))); + + await this.afterWrite(); + return existing; + } + + async deleteByUserId(userId: string): Promise<{ + snippetsDeleted: number; + foldersDeleted: number; + }> { + const deletedSnippets = await this.context.drizzle + .delete(snippets) + .where(eq(snippets.userId, userId)) + .returning({ id: snippets.id }); + + const deletedFolders = await this.context.drizzle + .delete(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .returning({ id: snippetFolders.id }); + + if (deletedSnippets.length > 0 || deletedFolders.length > 0) { + await this.afterWrite(); + } + + return { + snippetsDeleted: deletedSnippets.length, + foldersDeleted: deletedFolders.length, + }; + } + + async bulkImport( + userId: string, + snippetsToImport: unknown[] | undefined, + foldersToImport: unknown[] | undefined, + overwrite: boolean, + ): Promise { + const results: SnippetBulkImportResult = { + snippetsImported: 0, + snippetsSkipped: 0, + snippetsUpdated: 0, + foldersImported: 0, + foldersSkipped: 0, + failed: 0, + errors: [], + }; + + let changed = false; + + if (Array.isArray(foldersToImport)) { + for (const rawFolder of foldersToImport) { + const folder = rawFolder as BulkImportFolderInput; + if (!isNonEmptyString(folder.name)) { + results.failed++; + results.errors.push(`Folder missing name`); + continue; + } + + const created = await this.createFolder( + userId, + folder.name, + folder.color, + folder.icon, + false, + ); + + if (!created) { + results.foldersSkipped++; + continue; + } + + changed = true; + results.foldersImported++; + } + } + + if (Array.isArray(snippetsToImport)) { + for (let i = 0; i < snippetsToImport.length; i++) { + const snippet = snippetsToImport[i] as BulkImportSnippetInput; + + if ( + !isNonEmptyString(snippet.name) || + !isNonEmptyString(snippet.content) + ) { + results.failed++; + results.errors.push( + `Snippet ${i + 1}: name and content are required`, + ); + continue; + } + + const folderVal = snippet.folder?.trim() || null; + const existing = await this.findByNameAndFolder( + userId, + snippet.name.trim(), + folderVal, + ); + + if (existing) { + if (!overwrite) { + results.snippetsSkipped++; + continue; + } + + await this.context.drizzle + .update(snippets) + .set({ + content: snippet.content.trim(), + description: snippet.description?.trim() || null, + folder: folderVal, + order: + typeof snippet.order === "number" + ? snippet.order + : existing.order, + hostFilter: snippet.hostFilter || null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and(eq(snippets.id, existing.id), eq(snippets.userId, userId)), + ); + changed = true; + results.snippetsUpdated++; + continue; + } + + const maxOrder = await this.maxOrderForFolder(userId, folderVal); + await this.context.drizzle.insert(snippets).values({ + userId, + name: snippet.name.trim(), + content: snippet.content.trim(), + description: snippet.description?.trim() || null, + folder: folderVal, + order: + typeof snippet.order === "number" ? snippet.order : maxOrder + 1, + hostFilter: snippet.hostFilter || null, + }); + changed = true; + results.snippetsImported++; + } + } + + if (changed) { + await this.afterWrite(); + } + + return results; + } + + async createFolder( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + triggerSave = true, + ): Promise { + const existing = await this.findFolderByName(userId, name); + if (existing) return null; + + const rows = await this.context.drizzle + .insert(snippetFolders) + .values({ + userId, + name: name.trim(), + color: color?.trim() || null, + icon: icon?.trim() || null, + }) + .returning(); + + if (triggerSave) { + await this.afterWrite(); + } + return rows[0] ?? null; + } + + async updateFolderMetadata( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + ): Promise { + const existing = await this.findFolderByName(userId, name); + if (!existing) return null; + + const updateFields: Partial<{ + color: string | null; + icon: string | null; + updatedAt: ReturnType; + }> = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (color !== undefined) updateFields.color = color?.trim() || null; + if (icon !== undefined) updateFields.icon = icon?.trim() || null; + + const rows = await this.context.drizzle + .update(snippetFolders) + .set(updateFields) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async renameFolder( + userId: string, + oldName: string, + newName: string, + ): Promise { + const existing = await this.findFolderByName(userId, oldName); + if (!existing) return { status: "missing" }; + + const nameExists = await this.findFolderByName(userId, newName); + if (nameExists) return { status: "conflict" }; + + await this.context.drizzle + .update(snippetFolders) + .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, oldName), + ), + ); + + await this.context.drizzle + .update(snippets) + .set({ folder: newName }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); + + await this.afterWrite(); + return { status: "renamed" }; + } + + async deleteFolder(userId: string, name: string): Promise { + await this.context.drizzle + .update(snippets) + .set({ folder: null }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); + + await this.context.drizzle + .delete(snippetFolders) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); + + await this.afterWrite(); + } + + private async findFolderByName( + userId: string, + name: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(snippetFolders) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async nextOrderForFolder( + userId: string, + folder: string, + ): Promise { + return (await this.maxOrderForFolder(userId, folder || null)) + 1; + } + + private async maxOrderForFolder( + userId: string, + folder: string | null, + ): Promise { + const maxOrderResult = await this.context.drizzle + .select({ maxOrder: sql`MAX(${snippets.order})` }) + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + folder + ? eq(snippets.folder, folder) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ); + const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; + return maxOrder; + } + + private async findByNameAndFolder( + userId: string, + name: string, + folder: string | null, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + eq(snippets.name, name), + folder + ? eq(snippets.folder, folder) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/src/backend/database/repositories/sqlite-foreign-keys.ts b/src/backend/database/repositories/sqlite-foreign-keys.ts new file mode 100644 index 00000000..09abf115 --- /dev/null +++ b/src/backend/database/repositories/sqlite-foreign-keys.ts @@ -0,0 +1,23 @@ +import { getCurrentRepositorySqlite } from "./factory.js"; + +export interface SqliteForeignKeyClient { + exec(sql: string): unknown; +} + +export async function withSqliteForeignKeysDisabled( + sqlite: SqliteForeignKeyClient, + operation: () => Promise, +): Promise { + sqlite.exec("PRAGMA foreign_keys = OFF"); + try { + return await operation(); + } finally { + sqlite.exec("PRAGMA foreign_keys = ON"); + } +} + +export async function withCurrentSqliteForeignKeysDisabled( + operation: () => Promise, +): Promise { + return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation); +} diff --git a/src/backend/database/repositories/ssh-credential-usage-repository.ts b/src/backend/database/repositories/ssh-credential-usage-repository.ts new file mode 100644 index 00000000..97d1aa0e --- /dev/null +++ b/src/backend/database/repositories/ssh-credential-usage-repository.ts @@ -0,0 +1,79 @@ +import { eq, inArray } from "drizzle-orm"; +import { sshCredentialUsage } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect; + +export class SshCredentialUsageRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(sshCredentialUsage) + .where(eq(sshCredentialUsage.userId, userId)); + } + + async create( + credentialId: number, + hostId: number, + userId: string, + ): Promise { + const [created] = await this.context.drizzle + .insert(sshCredentialUsage) + .values({ credentialId, hostId, userId }) + .returning(); + await this.afterWrite(); + return created; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sshCredentialUsage) + .where(eq(sshCredentialUsage.userId, userId)) + .returning({ id: sshCredentialUsage.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(sshCredentialUsage) + .where(eq(sshCredentialUsage.hostId, hostId)) + .returning({ id: sshCredentialUsage.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(sshCredentialUsage) + .where(inArray(sshCredentialUsage.hostId, hostIds)) + .returning({ id: sshCredentialUsage.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/sso-provider-repository.ts b/src/backend/database/repositories/sso-provider-repository.ts new file mode 100644 index 00000000..403a81a0 --- /dev/null +++ b/src/backend/database/repositories/sso-provider-repository.ts @@ -0,0 +1,127 @@ +import { asc, eq } from "drizzle-orm"; +import { ssoProviders, users } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SsoProviderRecord = typeof ssoProviders.$inferSelect; +export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert; +export type SsoProviderUpdate = Partial< + Pick< + NewSsoProviderRecord, + "name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt" + > +>; + +export type PublicSsoProviderRecord = Pick< + SsoProviderRecord, + "id" | "name" | "type" | "displayOrder" +>; + +export class SsoProviderRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listEnabledPublic(): Promise { + return this.context.drizzle + .select({ + id: ssoProviders.id, + name: ssoProviders.name, + type: ssoProviders.type, + displayOrder: ssoProviders.displayOrder, + }) + .from(ssoProviders) + .where(eq(ssoProviders.enabled, true)) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + } + + async listEnabled(): Promise { + return this.context.drizzle + .select() + .from(ssoProviders) + .where(eq(ssoProviders.enabled, true)) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + } + + async listAll(): Promise { + return this.context.drizzle + .select() + .from(ssoProviders) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(ssoProviders) + .where(eq(ssoProviders.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findFirstEnabledOidcLike(): Promise { + const rows = await this.context.drizzle + .select() + .from(ssoProviders) + .where(eq(ssoProviders.enabled, true)) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + + return ( + rows.find( + (row) => + row.type === "oidc" || row.type === "github" || row.type === "google", + ) ?? null + ); + } + + async create(provider: NewSsoProviderRecord): Promise { + const rows = await this.context.drizzle + .insert(ssoProviders) + .values(provider) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async update( + id: number, + update: SsoProviderUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(ssoProviders) + .set(update) + .where(eq(ssoProviders.id, id)) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async delete(id: number): Promise { + const rows = await this.context.drizzle + .delete(ssoProviders) + .where(eq(ssoProviders.id, id)) + .returning({ id: ssoProviders.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async countUsersByProviderId(providerId: number): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users) + .where(eq(users.ssoProviderId, providerId)); + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/termix-identity-ca-repository.ts b/src/backend/database/repositories/termix-identity-ca-repository.ts new file mode 100644 index 00000000..ea1fcd3b --- /dev/null +++ b/src/backend/database/repositories/termix-identity-ca-repository.ts @@ -0,0 +1,163 @@ +import { eq } from "drizzle-orm"; +import { termixIdentityCa } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; + +export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect; +export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert; +export type TermixIdentityCaUpdate = Partial< + Pick< + NewTermixIdentityCaRecord, + "publicKey" | "privateKey" | "validityDays" | "updatedAt" + > +>; + +export class TermixIdentityCaRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findPublicByIdentityId( + identityId: number, + ): Promise | null> { + const rows = await this.context.drizzle + .select({ + publicKey: termixIdentityCa.publicKey, + validityDays: termixIdentityCa.validityDays, + }) + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)) + .limit(1); + + return rows[0] ?? null; + } + + async findDecryptedByIdentityId( + userId: string, + identityId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)) + .limit(1); + + return this.decryptOne(rows[0] ?? null, userId); + } + + async createEncryptedForUser( + userId: string, + ca: NewTermixIdentityCaRecord, + ): Promise { + const userDataKey = DataCrypto.validateUserAccess(userId); + const result = this.context.drizzle.transaction((tx) => { + const inserted = tx + .insert(termixIdentityCa) + .values({ ...ca, privateKey: "" }) + .returning() + .all(); + const row = inserted[0]; + const encrypted = DataCrypto.encryptRecord( + "termix_identity_ca", + { id: row.id, privateKey: ca.privateKey }, + userId, + userDataKey, + ); + + return tx + .update(termixIdentityCa) + .set({ privateKey: encrypted.privateKey }) + .where(eq(termixIdentityCa.id, row.id)) + .returning() + .all()[0]; + }); + + await this.afterWrite(); + return DataCrypto.decryptRecord( + "termix_identity_ca", + result, + userId, + userDataKey, + ); + } + + async updateEncryptedForIdentity( + userId: string, + identityId: number, + update: TermixIdentityCaUpdate, + ): Promise { + const existing = await this.findDecryptedByIdentityId(userId, identityId); + if (!existing) return null; + + const userDataKey = DataCrypto.validateUserAccess(userId); + const encryptedPrivateKey = update.privateKey + ? DataCrypto.encryptRecord( + "termix_identity_ca", + { id: existing.id, privateKey: update.privateKey }, + userId, + userDataKey, + ).privateKey + : undefined; + + const rows = await this.context.drizzle + .update(termixIdentityCa) + .set({ + ...update, + ...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}), + }) + .where(eq(termixIdentityCa.identityId, identityId)) + .returning(); + + await this.afterWrite(); + return this.decryptOne(rows[0] ?? null, userId); + } + + async deleteByIdentityId(identityId: number): Promise { + const rows = await this.context.drizzle + .delete(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)) + .returning({ id: termixIdentityCa.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(termixIdentityCa) + .where(eq(termixIdentityCa.userId, userId)) + .returning({ id: termixIdentityCa.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + 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( + "termix_identity_ca", + record, + userId, + userDataKey, + ); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/termix-identity-repository.ts b/src/backend/database/repositories/termix-identity-repository.ts new file mode 100644 index 00000000..9badeedd --- /dev/null +++ b/src/backend/database/repositories/termix-identity-repository.ts @@ -0,0 +1,244 @@ +import { and, asc, eq } from "drizzle-orm"; +import { termixIdentities, termixIdentityKeys } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type TermixIdentityRecord = typeof termixIdentities.$inferSelect; +export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert; +export type TermixIdentityUpdate = Partial< + Pick +>; + +export type TermixIdentityKeyRecord = typeof termixIdentityKeys.$inferSelect; +export type NewTermixIdentityKeyRecord = typeof termixIdentityKeys.$inferInsert; +export type TermixIdentityKeyUpdate = Partial< + Pick +>; + +export class TermixIdentityRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findIdentityForUser( + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .limit(1); + + return rows[0] ?? null; + } + + async findIdentityByHandle( + handle: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + + return rows[0] ?? null; + } + + async isHandleTaken(handle: string): Promise { + const rows = await this.context.drizzle + .select({ id: termixIdentities.id }) + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + + return rows.length > 0; + } + + async createIdentity( + identity: NewTermixIdentityRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(termixIdentities) + .values(identity) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateIdentityForUser( + userId: string, + update: TermixIdentityUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(termixIdentities) + .set(update) + .where(eq(termixIdentities.userId, userId)) + .returning(); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows[0] ?? null; + } + + async deleteIdentityForUser(userId: string): Promise { + const rows = await this.context.drizzle + .delete(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .returning({ id: termixIdentities.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise<{ + identitiesDeleted: number; + keysDeleted: number; + }> { + const keyRows = await this.context.drizzle + .delete(termixIdentityKeys) + .where(eq(termixIdentityKeys.userId, userId)) + .returning({ id: termixIdentityKeys.id }); + + const identityRows = await this.context.drizzle + .delete(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .returning({ id: termixIdentities.id }); + + if (keyRows.length > 0 || identityRows.length > 0) { + await this.afterWrite(); + } + + return { + identitiesDeleted: identityRows.length, + keysDeleted: keyRows.length, + }; + } + + async listKeysByIdentityId( + identityId: number, + ): Promise { + return this.context.drizzle + .select() + .from(termixIdentityKeys) + .where(eq(termixIdentityKeys.identityId, identityId)) + .orderBy(asc(termixIdentityKeys.id)); + } + + async listEnabledKeysByIdentityId( + identityId: number, + ): Promise { + return this.context.drizzle + .select() + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.identityId, identityId), + eq(termixIdentityKeys.enabled, true), + ), + ) + .orderBy(asc(termixIdentityKeys.id)); + } + + async listLinkedCredentialIds(identityId: number): Promise { + const rows = await this.context.drizzle + .select({ credentialId: termixIdentityKeys.credentialId }) + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.identityId, identityId), + eq(termixIdentityKeys.enabled, true), + ), + ); + + return Array.from( + new Set( + rows + .map((row) => row.credentialId) + .filter( + (credentialId): credentialId is number => credentialId !== null, + ), + ), + ); + } + + async createKey( + key: NewTermixIdentityKeyRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(termixIdentityKeys) + .values(key) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateKeyForUser( + userId: string, + id: number, + update: TermixIdentityKeyUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(termixIdentityKeys) + .set(update) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .returning(); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows[0] ?? null; + } + + async deleteKeyForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .returning({ id: termixIdentityKeys.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async findKeyForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/tmux-session-tag-repository.ts b/src/backend/database/repositories/tmux-session-tag-repository.ts new file mode 100644 index 00000000..3d89d856 --- /dev/null +++ b/src/backend/database/repositories/tmux-session-tag-repository.ts @@ -0,0 +1,139 @@ +import { and, eq } from "drizzle-orm"; +import { tmuxSessionTags } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect; + +export interface TmuxSessionTagInput { + userId: string; + hostId: number; + sessionName: string; + tag: string; +} + +export class TmuxSessionTagRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserAndHost( + userId: string, + hostId: number, + ): Promise> { + const rows = await this.context.drizzle + .select() + .from(tmuxSessionTags) + .where( + and( + eq(tmuxSessionTags.userId, userId), + eq(tmuxSessionTags.hostId, hostId), + ), + ); + + const bySession = new Map(); + for (const row of rows) { + const tags = bySession.get(row.sessionName) ?? []; + tags.push(row.tag); + bySession.set(row.sessionName, tags); + } + return bySession; + } + + async renameSessionForHost( + hostId: number, + sessionName: string, + newSessionName: string, + ): Promise { + const rows = await this.context.drizzle + .update(tmuxSessionTags) + .set({ sessionName: newSessionName }) + .where( + and( + eq(tmuxSessionTags.hostId, hostId), + eq(tmuxSessionTags.sessionName, sessionName), + ), + ) + .returning({ id: tmuxSessionTags.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteSessionForHost( + hostId: number, + sessionName: string, + ): Promise { + const rows = await this.context.drizzle + .delete(tmuxSessionTags) + .where( + and( + eq(tmuxSessionTags.hostId, hostId), + eq(tmuxSessionTags.sessionName, sessionName), + ), + ) + .returning({ id: tmuxSessionTags.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async replaceForUserHostSession( + userId: string, + hostId: number, + sessionName: string, + tags: string[], + ): Promise { + const deletedRows = await this.context.drizzle + .delete(tmuxSessionTags) + .where( + and( + eq(tmuxSessionTags.userId, userId), + eq(tmuxSessionTags.hostId, hostId), + eq(tmuxSessionTags.sessionName, sessionName), + ), + ) + .returning({ id: tmuxSessionTags.id }); + + if (tags.length > 0) { + await this.context.drizzle.insert(tmuxSessionTags).values( + tags.map((tag) => ({ + userId, + hostId, + sessionName, + tag, + })), + ); + } + + const changedRows = deletedRows.length + tags.length; + if (changedRows > 0) { + await this.afterWrite(); + } + + return changedRows; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(tmuxSessionTags) + .where(eq(tmuxSessionTags.userId, userId)) + .returning({ id: tmuxSessionTags.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/transfer-recent-repository.ts b/src/backend/database/repositories/transfer-recent-repository.ts new file mode 100644 index 00000000..309b270e --- /dev/null +++ b/src/backend/database/repositories/transfer-recent-repository.ts @@ -0,0 +1,171 @@ +import { and, desc, eq, inArray, or } from "drizzle-orm"; +import { transferRecent } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type TransferRecentRecord = typeof transferRecent.$inferSelect; + +export interface TransferRecentDestinationInput { + sourceHostId: number; + destHostId: number; + destPath: string; + destPathLabel?: string | null; +} + +export class TransferRecentRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(transferRecent) + .where(eq(transferRecent.userId, userId)); + } + + async listBySourceHost( + userId: string, + sourceHostId: number, + limit = 10, + ): Promise { + return this.context.drizzle + .select() + .from(transferRecent) + .where( + and( + eq(transferRecent.userId, userId), + eq(transferRecent.sourceHostId, sourceHostId), + ), + ) + .orderBy(desc(transferRecent.lastUsed)) + .limit(limit); + } + + async upsertForDestination( + userId: string, + input: TransferRecentDestinationInput, + lastUsed = new Date().toISOString(), + ): Promise { + const [existing] = await this.context.drizzle + .select({ id: transferRecent.id }) + .from(transferRecent) + .where( + and( + eq(transferRecent.userId, userId), + eq(transferRecent.sourceHostId, input.sourceHostId), + eq(transferRecent.destHostId, input.destHostId), + eq(transferRecent.destPath, input.destPath), + ), + ) + .limit(1); + + if (existing) { + await this.context.drizzle + .update(transferRecent) + .set({ lastUsed }) + .where(eq(transferRecent.id, existing.id)); + } else { + await this.context.drizzle.insert(transferRecent).values({ + userId, + sourceHostId: input.sourceHostId, + destHostId: input.destHostId, + destPath: input.destPath, + destPathLabel: input.destPathLabel || input.destPath, + lastUsed, + }); + } + + await this.afterWrite(); + } + + async pruneSourceHost( + userId: string, + sourceHostId: number, + keep = 10, + ): Promise { + const rows = await this.context.drizzle + .select({ id: transferRecent.id }) + .from(transferRecent) + .where( + and( + eq(transferRecent.userId, userId), + eq(transferRecent.sourceHostId, sourceHostId), + ), + ) + .orderBy(desc(transferRecent.lastUsed)); + + const idsToDelete = rows.slice(keep).map((row) => row.id); + if (idsToDelete.length === 0) { + return 0; + } + + const deleted = await this.context.drizzle + .delete(transferRecent) + .where(inArray(transferRecent.id, idsToDelete)) + .returning({ id: transferRecent.id }); + + if (deleted.length > 0) { + await this.afterWrite(); + } + + return deleted.length; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(transferRecent) + .where(eq(transferRecent.userId, userId)) + .returning({ id: transferRecent.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(transferRecent) + .where( + or( + eq(transferRecent.sourceHostId, hostId), + eq(transferRecent.destHostId, hostId), + ), + ) + .returning({ id: transferRecent.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(transferRecent) + .where( + or( + inArray(transferRecent.sourceHostId, hostIds), + inArray(transferRecent.destHostId, hostIds), + ), + ) + .returning({ id: transferRecent.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/trusted-device-repository.ts b/src/backend/database/repositories/trusted-device-repository.ts new file mode 100644 index 00000000..ee0f4b7a --- /dev/null +++ b/src/backend/database/repositories/trusted-device-repository.ts @@ -0,0 +1,100 @@ +import { and, eq } from "drizzle-orm"; +import { trustedDevices } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type TrustedDeviceRecord = typeof trustedDevices.$inferSelect; +export type NewTrustedDeviceRecord = typeof trustedDevices.$inferInsert; + +export class TrustedDeviceRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserAndFingerprint( + userId: string, + deviceFingerprint: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async touch( + userId: string, + deviceFingerprint: string, + lastUsedAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(trustedDevices) + .set({ lastUsedAt }) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + await this.afterWrite(); + } + + async upsert(device: NewTrustedDeviceRecord): Promise { + const existing = await this.findByUserAndFingerprint( + device.userId, + device.deviceFingerprint, + ); + + if (existing) { + await this.context.drizzle + .update(trustedDevices) + .set({ + expiresAt: device.expiresAt, + lastUsedAt: device.lastUsedAt, + }) + .where( + and( + eq(trustedDevices.userId, device.userId), + eq(trustedDevices.deviceFingerprint, device.deviceFingerprint), + ), + ); + } else { + await this.context.drizzle.insert(trustedDevices).values(device); + } + + await this.afterWrite(); + } + + async deleteByUserAndFingerprint( + userId: string, + deviceFingerprint: string, + ): Promise { + await this.context.drizzle + .delete(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + await this.afterWrite(); + } + + async deleteByUserId(userId: string): Promise { + await this.context.drizzle + .delete(trustedDevices) + .where(eq(trustedDevices.userId, userId)); + await this.afterWrite(); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/user-data-export-repository.ts b/src/backend/database/repositories/user-data-export-repository.ts new file mode 100644 index 00000000..da72c26c --- /dev/null +++ b/src/backend/database/repositories/user-data-export-repository.ts @@ -0,0 +1,26 @@ +import { eq } from "drizzle-orm"; +import { hosts, sshCredentials } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type UserDataExportHostRecord = typeof hosts.$inferSelect; +export type UserDataExportCredentialRecord = typeof sshCredentials.$inferSelect; + +export class UserDataExportRepository { + constructor(private readonly context: DatabaseContext) {} + + async listHostsByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.userId, userId)); + } + + async listCredentialsByUserId( + userId: string, + ): Promise { + return this.context.drizzle + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)); + } +} diff --git a/src/backend/database/repositories/user-preference-repository.ts b/src/backend/database/repositories/user-preference-repository.ts new file mode 100644 index 00000000..7163c284 --- /dev/null +++ b/src/backend/database/repositories/user-preference-repository.ts @@ -0,0 +1,67 @@ +import { eq } from "drizzle-orm"; +import { userPreferences } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type UserPreferenceRecord = typeof userPreferences.$inferSelect; +export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert; +export type UserPreferenceUpdate = Partial< + Omit +>; + +export class UserPreferenceRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .limit(1); + + return rows[0] ?? null; + } + + async upsert( + userId: string, + update: UserPreferenceUpdate, + ): Promise { + const existing = await this.findByUserId(userId); + + if (!existing) { + const rows = await this.context.drizzle + .insert(userPreferences) + .values({ userId, ...update }) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + const rows = await this.context.drizzle + .update(userPreferences) + .set(update) + .where(eq(userPreferences.userId, userId)) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(userPreferences) + .where(eq(userPreferences.userId, userId)) + .returning({ userId: userPreferences.userId }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts new file mode 100644 index 00000000..68442f4f --- /dev/null +++ b/src/backend/database/repositories/user-repository.ts @@ -0,0 +1,161 @@ +import { eq, inArray } from "drizzle-orm"; +import { users } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type UserRecord = typeof users.$inferSelect; +export type NewUserRecord = typeof users.$inferInsert; +export type UserUpdate = Partial>; +export type NewFirstLocalUserRecord = Omit; + +export class UserRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listAll(): Promise { + return this.context.drizzle.select().from(users); + } + + async findById(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findByUsername(username: string): Promise { + const rows = await this.context.drizzle + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + return rows[0] ?? null; + } + + async findByOidcIdentifier( + oidcIdentifier: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(users) + .where(eq(users.oidcIdentifier, oidcIdentifier)) + .limit(1); + + return rows[0] ?? null; + } + + async listByIds(ids: string[]): Promise { + const uniqueIds = Array.from(new Set(ids.filter(Boolean))); + if (uniqueIds.length === 0) { + return []; + } + + return this.context.drizzle + .select() + .from(users) + .where(inArray(users.id, uniqueIds)); + } + + async create(user: NewUserRecord): Promise { + const rows = await this.context.drizzle + .insert(users) + .values(user) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async createFirstLocalUser( + user: NewFirstLocalUserRecord, + ): Promise<{ user: UserRecord; isFirstUser: boolean }> { + const result = this.context.drizzle.transaction((tx) => { + const existingUsers = tx.select({ id: users.id }).from(users).all(); + const isFirstUser = existingUsers.length === 0; + const rows = tx + .insert(users) + .values({ ...user, isAdmin: isFirstUser }) + .returning() + .all(); + + return { user: rows[0], isFirstUser }; + }); + + await this.afterWrite(); + return result; + } + + async createFirstSsoUser( + user: NewUserRecord, + ): Promise<{ user: UserRecord; isFirstUser: boolean }> { + const result = this.context.drizzle.transaction((tx) => { + const existingUsers = tx.select({ id: users.id }).from(users).all(); + const isFirstUser = existingUsers.length === 0; + const rows = tx + .insert(users) + .values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) }) + .returning() + .all(); + + return { user: rows[0], isFirstUser }; + }); + + await this.afterWrite(); + return result; + } + + async update(id: string, update: UserUpdate): Promise { + const rows = await this.context.drizzle + .update(users) + .set(update) + .where(eq(users.id, id)) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async delete(id: string): Promise { + const rows = await this.context.drizzle + .delete(users) + .where(eq(users.id, id)) + .returning({ id: users.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async countAdmins(): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users) + .where(eq(users.isAdmin, true)); + + return rows.length; + } + + async countTotpEnabled(): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users) + .where(eq(users.totpEnabled, true)); + + return rows.length; + } + + async countAll(): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users); + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/vault-profile-repository.ts b/src/backend/database/repositories/vault-profile-repository.ts new file mode 100644 index 00000000..319a7b4d --- /dev/null +++ b/src/backend/database/repositories/vault-profile-repository.ts @@ -0,0 +1,130 @@ +import { desc, eq, or } from "drizzle-orm"; +import { vaultProfiles } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type VaultProfileRecord = typeof vaultProfiles.$inferSelect; + +export interface VaultProfileCreateInput { + userId: string; + name: string; + description?: string | null; + folder?: string | null; + tags?: string | null; + vaultAddr: string; + vaultNamespace?: string | null; + oidcMount?: string | null; + oidcRole?: string | null; + sshMount?: string | null; + sshRole: string; + validPrincipals?: string | null; + keyType?: string | null; + shared?: boolean; +} + +export type VaultProfileUpdateInput = Partial< + Omit +> & { + updatedAt?: string; +}; + +export class VaultProfileRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listVisibleToUser(userId: string): Promise { + return this.context.drizzle + .select() + .from(vaultProfiles) + .where( + or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)), + ) + .orderBy(desc(vaultProfiles.updatedAt)); + } + + async create(input: VaultProfileCreateInput): Promise { + const [created] = await this.context.drizzle + .insert(vaultProfiles) + .values({ + userId: input.userId, + name: input.name, + description: input.description, + folder: input.folder, + tags: input.tags, + vaultAddr: input.vaultAddr, + vaultNamespace: input.vaultNamespace, + oidcMount: input.oidcMount, + oidcRole: input.oidcRole, + sshMount: input.sshMount, + sshRole: input.sshRole, + validPrincipals: input.validPrincipals, + keyType: input.keyType, + shared: input.shared ?? false, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(vaultProfiles) + .where(eq(vaultProfiles.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async updateById( + id: number, + input: VaultProfileUpdateInput, + ): Promise { + const [updated] = await this.context.drizzle + .update(vaultProfiles) + .set({ + ...input, + updatedAt: input.updatedAt ?? new Date().toISOString(), + }) + .where(eq(vaultProfiles.id, id)) + .returning(); + + if (updated) { + await this.afterWrite(); + } + + return updated ?? null; + } + + async deleteById(id: number): Promise { + const rows = await this.context.drizzle + .delete(vaultProfiles) + .where(eq(vaultProfiles.id, id)) + .returning({ id: vaultProfiles.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(vaultProfiles) + .where(eq(vaultProfiles.userId, userId)) + .returning({ id: vaultProfiles.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/vault-token-repository.ts b/src/backend/database/repositories/vault-token-repository.ts new file mode 100644 index 00000000..1db262f0 --- /dev/null +++ b/src/backend/database/repositories/vault-token-repository.ts @@ -0,0 +1,125 @@ +import { and, eq } from "drizzle-orm"; +import { vaultTokens } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type VaultTokenRecord = typeof vaultTokens.$inferSelect; + +export interface VaultTokenUpsertInput { + userId: string; + profileId: number; + sshCert: string; + privateKey: string; + expiresAt: string; + createdAt?: string; +} + +export class VaultTokenRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async upsert(input: VaultTokenUpsertInput): Promise { + const createdAt = input.createdAt ?? new Date().toISOString(); + + await this.context.drizzle + .insert(vaultTokens) + .values({ + userId: input.userId, + profileId: input.profileId, + sshCert: input.sshCert, + privateKey: input.privateKey, + expiresAt: input.expiresAt, + }) + .onConflictDoUpdate({ + target: [vaultTokens.userId, vaultTokens.profileId], + set: { + sshCert: input.sshCert, + privateKey: input.privateKey, + expiresAt: input.expiresAt, + createdAt, + }, + }); + + await this.afterWrite(); + } + + async findByUserAndProfile( + userId: string, + profileId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(vaultTokens) + .where( + and( + eq(vaultTokens.userId, userId), + eq(vaultTokens.profileId, profileId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async updateLastUsed( + userId: string, + profileId: number, + lastUsed = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .update(vaultTokens) + .set({ lastUsed }) + .where( + and( + eq(vaultTokens.userId, userId), + eq(vaultTokens.profileId, profileId), + ), + ) + .returning({ id: vaultTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserAndProfile( + userId: string, + profileId: number, + ): Promise { + const rows = await this.context.drizzle + .delete(vaultTokens) + .where( + and( + eq(vaultTokens.userId, userId), + eq(vaultTokens.profileId, profileId), + ), + ) + .returning({ id: vaultTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(vaultTokens) + .where(eq(vaultTokens.userId, userId)) + .returning({ id: vaultTokens.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/webauthn-credential-repository.ts b/src/backend/database/repositories/webauthn-credential-repository.ts new file mode 100644 index 00000000..f5dac86d --- /dev/null +++ b/src/backend/database/repositories/webauthn-credential-repository.ts @@ -0,0 +1,86 @@ +import { and, eq } from "drizzle-orm"; +import { webauthnCredentials } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect; +export type NewWebauthnCredentialRecord = + typeof webauthnCredentials.$inferInsert; + +export interface WebauthnCredentialAuthState { + counter: number; + backedUp: boolean; + deviceType: string | null; + lastUsedAt: string; +} + +export class WebauthnCredentialRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)); + } + + async findByCredentialId( + credentialId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.credentialId, credentialId)) + .limit(1); + + return rows[0] ?? null; + } + + async create( + record: NewWebauthnCredentialRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(webauthnCredentials) + .values(record) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateAuthState( + id: string, + state: WebauthnCredentialAuthState, + ): Promise { + await this.context.drizzle + .update(webauthnCredentials) + .set(state) + .where(eq(webauthnCredentials.id, id)); + + await this.afterWrite(); + } + + async deleteForUser(userId: string, id: string): Promise { + const rows = await this.context.drizzle + .delete(webauthnCredentials) + .where( + and( + eq(webauthnCredentials.id, id), + eq(webauthnCredentials.userId, userId), + ), + ) + .returning({ id: webauthnCredentials.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts index 8d55f65e..317a08a9 100644 --- a/src/backend/database/routes/acme-ssl-routes.ts +++ b/src/backend/database/routes/acme-ssl-routes.ts @@ -1,13 +1,15 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { promises as fs } from "fs"; import path from "path"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentSettingsRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import type { UserRecord } from "../repositories/user-repository.js"; const DATA_DIR = process.env.DATA_DIR || "./db/data"; const SSL_DIR = path.join(DATA_DIR, "ssl"); @@ -33,13 +35,21 @@ export type AcmeSettings = { certExpiresAt: string | null; }; +async function getAdminActor( + userId: string | undefined, +): Promise { + if (!userId) return null; + const user = await createCurrentUserRepository().findById(userId); + return user?.isAdmin ? user : null; +} + function getCertInfo(): { status: "none" | "valid" | "expiring" | "expired"; expiresAt: string | null; } { const certFile = path.join(SSL_DIR, "termix.crt"); try { - execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, { + execFileSync("openssl", ["x509", "-in", certFile, "-noout"], { stdio: "pipe", }); } catch { @@ -47,8 +57,9 @@ function getCertInfo(): { } try { - const endDateRaw = execSync( - `openssl x509 -in "${certFile}" -noout -enddate`, + const endDateRaw = execFileSync( + "openssl", + ["x509", "-in", certFile, "-noout", "-enddate"], { stdio: "pipe" }, ) .toString() @@ -57,17 +68,25 @@ function getCertInfo(): { const expiresAt = new Date(endDateRaw).toISOString(); try { - execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, { - stdio: "pipe", - }); + execFileSync( + "openssl", + ["x509", "-in", certFile, "-checkend", "0", "-noout"], + { + stdio: "pipe", + }, + ); } catch { return { status: "expired", expiresAt }; } try { - execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, { - stdio: "pipe", - }); + execFileSync( + "openssl", + ["x509", "-in", certFile, "-checkend", "2592000", "-noout"], + { + stdio: "pipe", + }, + ); return { status: "valid", expiresAt }; } catch { return { status: "expiring", expiresAt }; @@ -77,13 +96,11 @@ function getCertInfo(): { } } -function getAcmeSettingsFromDb(): AcmeSettings { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") - .get() as { value: string } | undefined; - +async function getAcmeSettings(): Promise { const { status, expiresAt } = getCertInfo(); - const stored = row ? JSON.parse(row.value) : {}; + const value = + await createCurrentSettingsRepository().get("acme_ssl_settings"); + const stored = value ? JSON.parse(value) : {}; return { enabled: stored.enabled ?? false, @@ -119,7 +136,7 @@ export function registerAcmeSSLRoutes( */ router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => { try { - res.json(getAcmeSettingsFromDb()); + res.json(await getAcmeSettings()); } catch (err) { authLogger.error("Failed to get ACME SSL settings", err); res.status(500).json({ error: "Failed to get ACME SSL settings" }); @@ -163,15 +180,14 @@ export function registerAcmeSSLRoutes( router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } - const existing = db.$client - .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") - .get() as { value: string } | undefined; - const current = existing ? JSON.parse(existing.value) : {}; + const settingsRepository = createCurrentSettingsRepository(); + const existing = await settingsRepository.get("acme_ssl_settings"); + const current = existing ? JSON.parse(existing) : {}; const { enabled, domain, email, challengeType, cloudflareToken } = req.body; @@ -187,21 +203,15 @@ export function registerAcmeSSLRoutes( !cloudflareToken.includes("*") && { cloudflareToken }), }; - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)", - ) - .run(JSON.stringify(updated)); + await settingsRepository.set( + "acme_ssl_settings", + JSON.stringify(updated), + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_acme_ssl_settings", resourceType: "setting", details: JSON.stringify({ @@ -216,7 +226,7 @@ export function registerAcmeSSLRoutes( success: true, }); - res.json(getAcmeSettingsFromDb()); + res.json(await getAcmeSettings()); } catch (err) { authLogger.error("Failed to update ACME SSL settings", err); res.status(500).json({ error: "Failed to update ACME SSL settings" }); @@ -243,21 +253,20 @@ export function registerAcmeSSLRoutes( */ router.post("/acme-ssl-request", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; + const actor = await getAdminActor(userId); try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") - .get() as { value: string } | undefined; + const settingsValue = + await createCurrentSettingsRepository().get("acme_ssl_settings"); - if (!row) { + if (!settingsValue) { return res.status(400).json({ error: "ACME settings not configured" }); } - const settings = JSON.parse(row.value); + const settings = JSON.parse(settingsValue); const { domain, email, challengeType, cloudflareToken } = settings; if (!domain || !email) { @@ -265,7 +274,7 @@ export function registerAcmeSSLRoutes( } try { - execSync("certbot --version", { stdio: "pipe" }); + execFileSync("certbot", ["--version"], { stdio: "pipe" }); } catch { return res .status(500) @@ -278,13 +287,16 @@ export function registerAcmeSSLRoutes( await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true }); await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true }); - const certbotDirFlags = [ - `--config-dir "${CERTBOT_CONFIG_DIR}"`, - `--work-dir "${CERTBOT_WORK_DIR}"`, - `--logs-dir "${CERTBOT_LOGS_DIR}"`, - ].join(" "); + const certbotDirArgs = [ + "--config-dir", + CERTBOT_CONFIG_DIR, + "--work-dir", + CERTBOT_WORK_DIR, + "--logs-dir", + CERTBOT_LOGS_DIR, + ]; - let certbotCmd: string; + let certbotArgs: string[]; if (challengeType === "dns-cloudflare") { if (!cloudflareToken) { @@ -302,40 +314,39 @@ export function registerAcmeSSLRoutes( { mode: 0o600 }, ); - certbotCmd = [ - "certbot", + certbotArgs = [ "certonly", "--non-interactive", "--agree-tos", "--dns-cloudflare", - `--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`, + "--dns-cloudflare-credentials", + CLOUDFLARE_CREDENTIALS_FILE, "--dns-cloudflare-propagation-seconds", "30", "-d", - `"${domain}"`, + domain, "--email", - `"${email}"`, + email, "--cert-name", "termix", - certbotDirFlags, - ].join(" "); + ...certbotDirArgs, + ]; } else { - certbotCmd = [ - "certbot", + certbotArgs = [ "certonly", "--non-interactive", "--agree-tos", "--webroot", "-w", - `"${ACME_WEBROOT}"`, + ACME_WEBROOT, "-d", - `"${domain}"`, + domain, "--email", - `"${email}"`, + email, "--cert-name", "termix", - certbotDirFlags, - ].join(" "); + ...certbotDirArgs, + ]; } authLogger.info("Requesting Let's Encrypt certificate", { @@ -344,7 +355,10 @@ export function registerAcmeSSLRoutes( operation: "acme_cert_request", }); - execSync(certbotCmd, { stdio: "pipe", timeout: 120000 }); + execFileSync("certbot", certbotArgs, { + stdio: "pipe", + timeout: 120000, + }); const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix"); const fullchainSrc = path.join(liveDir, "fullchain.pem"); @@ -358,11 +372,10 @@ export function registerAcmeSSLRoutes( await fs.chmod(certDest, 0o644); const updated = { ...settings, lastIssuedAt: new Date().toISOString() }; - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)", - ) - .run(JSON.stringify(updated)); + await createCurrentSettingsRepository().set( + "acme_ssl_settings", + JSON.stringify(updated), + ); authLogger.info("Let's Encrypt certificate issued and installed", { domain, @@ -370,14 +383,9 @@ export function registerAcmeSSLRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "acme_ssl_request", resourceType: "setting", details: JSON.stringify({ domain, challengeType, success: true }), @@ -386,20 +394,15 @@ export function registerAcmeSSLRoutes( success: true, }); - res.json({ success: true, ...getAcmeSettingsFromDb() }); + res.json({ success: true, ...(await getAcmeSettings()) }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; authLogger.error("ACME certificate request failed", err); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor?.username ?? userId, action: "acme_ssl_request", resourceType: "setting", details: JSON.stringify({ error: message }), diff --git a/src/backend/database/routes/alert-rules-routes.ts b/src/backend/database/routes/alert-rules-routes.ts index 10b8d7e0..5ce379a4 100644 --- a/src/backend/database/routes/alert-rules-routes.ts +++ b/src/backend/database/routes/alert-rules-routes.ts @@ -1,8 +1,7 @@ import express, { type Request, type Response } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { getDb } from "../db/index.js"; +import { createCurrentAlertRepository } from "../repositories/factory.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { DatabaseSaveTrigger } from "../db/index.js"; import { databaseLogger } from "../../utils/logger.js"; import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js"; @@ -36,14 +35,11 @@ router.use(authenticateJWT); * 200: * description: List of notification channels. */ -router.get("/notification-channels", (req, res) => { +router.get("/notification-channels", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = getDb() - .$client.prepare( - "SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC", - ) - .all(userId); + const rows = + await createCurrentAlertRepository().listNotificationChannels(userId); res.json(rows); } catch (err) { databaseLogger.error("Failed to list notification channels", { @@ -62,7 +58,7 @@ router.get("/notification-channels", (req, res) => { * tags: * - Alerts */ -router.post("/notification-channels", (req, res) => { +router.post("/notification-channels", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { name, type, config, enabled } = req.body as { name: string; @@ -94,22 +90,13 @@ router.post("/notification-channels", (req, res) => { } try { - const result = getDb() - .$client.prepare( - `INSERT INTO notification_channels (user_id, name, type, config, enabled) - VALUES (?, ?, ?, ?, ?)`, - ) - .run( - userId, - name.trim(), - type, - JSON.stringify(config), - enabled !== false ? 1 : 0, - ); - const row = getDb() - .$client.prepare("SELECT * FROM notification_channels WHERE id = ?") - .get(result.lastInsertRowid); - DatabaseSaveTrigger.triggerSave("notification_channel_created"); + const row = await createCurrentAlertRepository().createNotificationChannel({ + userId, + name: name.trim(), + type, + config: JSON.stringify(config), + enabled: enabled !== false, + }); res.status(201).json(row); } catch (err) { databaseLogger.error("Failed to create notification channel", { @@ -128,69 +115,61 @@ router.post("/notification-channels", (req, res) => { * tags: * - Alerts */ -router.put("/notification-channels/:id", (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const channelId = Number(req.params.id); - const { name, type, config, enabled } = req.body as { - name?: string; - type?: string; - config?: unknown; - enabled?: boolean; - }; +router.put( + "/notification-channels/:id", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const { name, type, config, enabled } = req.body as { + name?: string; + type?: string; + config?: unknown; + enabled?: boolean; + }; - const existing = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!existing) return res.status(404).json({ error: "Channel not found" }); + const repository = createCurrentAlertRepository(); + const existing = await repository.findNotificationChannelForUser( + channelId, + userId, + ); + if (!existing) return res.status(404).json({ error: "Channel not found" }); - if (type && type !== "webhook" && type !== "ntfy") { - return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" }); - } - - try { - const updates: string[] = []; - const params: unknown[] = []; - if (name !== undefined) { - updates.push("name = ?"); - params.push(name.trim()); + if (type && type !== "webhook" && type !== "ntfy") { + return res + .status(400) + .json({ error: "type must be 'webhook' or 'ntfy'" }); } - if (type !== undefined) { - updates.push("type = ?"); - params.push(type); - } - if (config !== undefined) { - updates.push("config = ?"); - params.push(JSON.stringify(config)); - } - if (enabled !== undefined) { - updates.push("enabled = ?"); - params.push(enabled ? 1 : 0); + if ( + name === undefined && + type === undefined && + config === undefined && + enabled === undefined + ) { + return res.json({ success: true }); } - if (updates.length === 0) return res.json({ success: true }); - - params.push(channelId, userId); - getDb() - .$client.prepare( - `UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, - ) - .run(...params); - - const row = getDb() - .$client.prepare("SELECT * FROM notification_channels WHERE id = ?") - .get(channelId); - DatabaseSaveTrigger.triggerSave("notification_channel_updated"); - res.json(row); - } catch (err) { - databaseLogger.error("Failed to update notification channel", { - operation: "update_channel", - error: err, - }); - res.status(500).json({ error: "Failed to update channel" }); - } -}); + try { + const row = await repository.updateNotificationChannel( + channelId, + userId, + { + ...(name !== undefined ? { name: name.trim() } : {}), + ...(type !== undefined ? { type } : {}), + ...(config !== undefined ? { config: JSON.stringify(config) } : {}), + ...(enabled !== undefined ? { enabled } : {}), + }, + ); + if (!row) return res.status(404).json({ error: "Channel not found" }); + res.json(row); + } catch (err) { + databaseLogger.error("Failed to update notification channel", { + operation: "update_channel", + error: err, + }); + res.status(500).json({ error: "Failed to update channel" }); + } + }, +); /** * @openapi @@ -200,21 +179,20 @@ router.put("/notification-channels/:id", (req: Request, res: Response) => { * tags: * - Alerts */ -router.delete("/notification-channels/:id", (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const channelId = Number(req.params.id); - const existing = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!existing) return res.status(404).json({ error: "Channel not found" }); - getDb() - .$client.prepare("DELETE FROM notification_channels WHERE id = ?") - .run(channelId); - DatabaseSaveTrigger.triggerSave("notification_channel_deleted"); - res.json({ success: true }); -}); +router.delete( + "/notification-channels/:id", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const deleted = + await createCurrentAlertRepository().deleteNotificationChannel( + channelId, + userId, + ); + if (!deleted) return res.status(404).json({ error: "Channel not found" }); + res.json({ success: true }); + }, +); /** * @openapi @@ -229,11 +207,11 @@ router.post( async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const channelId = Number(req.params.id); - const row = getDb() - .$client.prepare( - "SELECT * FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId) as { type: string; config: string } | undefined; + const row = + await createCurrentAlertRepository().findNotificationChannelForUser( + channelId, + userId, + ); if (!row) return res.status(404).json({ error: "Channel not found" }); const testPayload = { @@ -288,33 +266,10 @@ router.post( * tags: * - Alerts */ -router.get("/alert-rules", (req, res) => { +router.get("/alert-rules", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const rules = getDb() - .$client.prepare( - "SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC", - ) - .all(userId) as Array<{ id: number }>; - - const channelMap = new Map(); - for (const rule of rules) { - const channels = getDb() - .$client.prepare( - "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?", - ) - .all(rule.id) as Array<{ channel_id: number }>; - channelMap.set( - rule.id, - channels.map((c) => c.channel_id), - ); - } - - const result = rules.map((r) => ({ - ...r, - channels: channelMap.get(r.id) ?? [], - })); - res.json(result); + res.json(await createCurrentAlertRepository().listAlertRules(userId)); } catch (err) { databaseLogger.error("Failed to list alert rules", { operation: "list_alert_rules", @@ -332,7 +287,7 @@ router.get("/alert-rules", (req, res) => { * tags: * - Alerts */ -router.post("/alert-rules", (req, res) => { +router.post("/alert-rules", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { name, @@ -373,45 +328,18 @@ router.post("/alert-rules", (req, res) => { try { const now = new Date().toISOString(); - const result = getDb() - .$client.prepare( - `INSERT INTO alert_rules - (user_id, host_id, name, enabled, trigger_type, threshold_value, - threshold_duration_seconds, cooldown_minutes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - userId, - hostId ?? null, - name.trim(), - enabled !== false ? 1 : 0, - triggerType, - thresholdValue ?? null, - thresholdDurationSeconds ?? null, - cooldownMinutes ?? 15, - now, - now, - ); - const ruleId = result.lastInsertRowid as number; - - for (const channelId of channels) { - const owned = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!owned) continue; - getDb() - .$client.prepare( - "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)", - ) - .run(ruleId, channelId); - } - - const row = getDb() - .$client.prepare("SELECT * FROM alert_rules WHERE id = ?") - .get(ruleId) as Record; - DatabaseSaveTrigger.triggerSave("alert_rule_created"); + const row = await createCurrentAlertRepository().createAlertRule({ + userId, + hostId: hostId ?? null, + name: name.trim(), + enabled: enabled !== false, + triggerType, + thresholdValue: thresholdValue ?? null, + thresholdDurationSeconds: thresholdDurationSeconds ?? null, + cooldownMinutes: cooldownMinutes ?? 15, + channels, + now, + }); res.status(201).json({ ...row, channels }); } catch (err) { databaseLogger.error("Failed to create alert rule", { @@ -430,13 +358,12 @@ router.post("/alert-rules", (req, res) => { * tags: * - Alerts */ -router.put("/alert-rules/:id", (req: Request, res: Response) => { +router.put("/alert-rules/:id", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const ruleId = Number(req.params.id); - const existing = getDb() - .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?") - .get(ruleId, userId); + const repository = createCurrentAlertRepository(); + const existing = await repository.findAlertRuleForUser(ruleId, userId); if (!existing) return res.status(404).json({ error: "Alert rule not found" }); const { @@ -464,76 +391,23 @@ router.put("/alert-rules/:id", (req: Request, res: Response) => { } try { - const updates: string[] = ["updated_at = ?"]; - const params: unknown[] = [new Date().toISOString()]; - if (name !== undefined) { - updates.push("name = ?"); - params.push(name.trim()); - } - if (hostId !== undefined) { - updates.push("host_id = ?"); - params.push(hostId ?? null); - } - if (enabled !== undefined) { - updates.push("enabled = ?"); - params.push(enabled ? 1 : 0); - } - if (triggerType !== undefined) { - updates.push("trigger_type = ?"); - params.push(triggerType); - } - if (thresholdValue !== undefined) { - updates.push("threshold_value = ?"); - params.push(thresholdValue ?? null); - } - if (thresholdDurationSeconds !== undefined) { - updates.push("threshold_duration_seconds = ?"); - params.push(thresholdDurationSeconds ?? null); - } - if (cooldownMinutes !== undefined) { - updates.push("cooldown_minutes = ?"); - params.push(cooldownMinutes); - } - params.push(ruleId, userId); - - getDb() - .$client.prepare( - `UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, - ) - .run(...params); - - if (channels !== undefined) { - getDb() - .$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?") - .run(ruleId); - for (const channelId of channels) { - const owned = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!owned) continue; - getDb() - .$client.prepare( - "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)", - ) - .run(ruleId, channelId); - } - } - - const row = getDb() - .$client.prepare("SELECT * FROM alert_rules WHERE id = ?") - .get(ruleId) as Record; - const linkedChannels = ( - getDb() - .$client.prepare( - "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?", - ) - .all(ruleId) as Array<{ channel_id: number }> - ).map((c) => c.channel_id); - - DatabaseSaveTrigger.triggerSave("alert_rule_updated"); - res.json({ ...row, channels: linkedChannels }); + const row = await repository.updateAlertRule(ruleId, userId, { + ...(name !== undefined ? { name: name.trim() } : {}), + ...(hostId !== undefined ? { hostId: hostId ?? null } : {}), + ...(enabled !== undefined ? { enabled } : {}), + ...(triggerType !== undefined ? { triggerType } : {}), + ...(thresholdValue !== undefined + ? { thresholdValue: thresholdValue ?? null } + : {}), + ...(thresholdDurationSeconds !== undefined + ? { thresholdDurationSeconds: thresholdDurationSeconds ?? null } + : {}), + ...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}), + ...(channels !== undefined ? { channels } : {}), + now: new Date().toISOString(), + }); + if (!row) return res.status(404).json({ error: "Alert rule not found" }); + res.json(row); } catch (err) { databaseLogger.error("Failed to update alert rule", { operation: "update_alert_rule", @@ -551,15 +425,14 @@ router.put("/alert-rules/:id", (req: Request, res: Response) => { * tags: * - Alerts */ -router.delete("/alert-rules/:id", (req: Request, res: Response) => { +router.delete("/alert-rules/:id", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const ruleId = Number(req.params.id); - const existing = getDb() - .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?") - .get(ruleId, userId); - if (!existing) return res.status(404).json({ error: "Alert rule not found" }); - getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId); - DatabaseSaveTrigger.triggerSave("alert_rule_deleted"); + const deleted = await createCurrentAlertRepository().deleteAlertRule( + ruleId, + userId, + ); + if (!deleted) return res.status(404).json({ error: "Alert rule not found" }); res.json({ success: true }); }); @@ -573,42 +446,27 @@ router.delete("/alert-rules/:id", (req: Request, res: Response) => { * tags: * - Alerts */ -router.get("/alert-firings", (req, res) => { +router.get("/alert-firings", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const limit = Math.min(Number(req.query.limit) || 50, 200); const offset = Number(req.query.offset) || 0; const acknowledgedParam = req.query.acknowledged; try { - let whereClause = "af.user_id = ?"; - const params: unknown[] = [userId]; - - if (acknowledgedParam === "true") { - whereClause += " AND af.acknowledged = 1"; - } else if (acknowledgedParam === "false") { - whereClause += " AND af.acknowledged = 0"; - } - - const rows = getDb() - .$client.prepare( - `SELECT af.*, ar.name as rule_name - FROM alert_firings af - LEFT JOIN alert_rules ar ON ar.id = af.rule_id - WHERE ${whereClause} - ORDER BY af.fired_at DESC - LIMIT ? OFFSET ?`, - ) - .all(...params, limit, offset); - - const total = ( - getDb() - .$client.prepare( - `SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`, - ) - .get(...params) as { c: number } - ).c; - - res.json({ firings: rows, total }); + const acknowledged = + acknowledgedParam === "true" + ? true + : acknowledgedParam === "false" + ? false + : undefined; + res.json( + await createCurrentAlertRepository().listAlertFirings({ + userId, + acknowledged, + limit, + offset, + }), + ); } catch (err) { databaseLogger.error("Failed to list alert firings", { operation: "list_alert_firings", @@ -626,17 +484,15 @@ router.get("/alert-firings", (req, res) => { * tags: * - Alerts */ -router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const firingId = Number(req.params.id); - getDb() - .$client.prepare( - "UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?", - ) - .run(firingId, userId); - DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged"); - res.json({ success: true }); -}); +router.post( + "/alert-firings/:id/acknowledge", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const firingId = Number(req.params.id); + await createCurrentAlertRepository().acknowledgeFiring(firingId, userId); + res.json({ success: true }); + }, +); /** * @openapi @@ -646,14 +502,9 @@ router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => { * tags: * - Alerts */ -router.post("/alert-firings/acknowledge-all", (req, res) => { +router.post("/alert-firings/acknowledge-all", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - getDb() - .$client.prepare( - "UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?", - ) - .run(userId); - DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all"); + await createCurrentAlertRepository().acknowledgeAllFirings(userId); res.json({ success: true }); }); diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index aa66ec57..5cbff237 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -4,12 +4,10 @@ import type { TermixAlert, } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { dismissedAlerts } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { getProxyAgent } from "../../utils/proxy-agent.js"; +import { createCurrentDismissedAlertRepository } from "../repositories/factory.js"; class AlertCache { private cache: Map = new Map(); @@ -120,13 +118,10 @@ router.get("/", authenticateJWT, async (req, res) => { const allAlerts = await fetchAlertsFromGitHub(); - const dismissedAlertRecords = await db - .select({ alertId: dismissedAlerts.alertId }) - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); - const dismissedAlertIds = new Set( - dismissedAlertRecords.map((record) => record.alertId), + await createCurrentDismissedAlertRepository().listAlertIdsByUserId( + userId, + ), ); const activeAlertsForUser = allAlerts.filter( @@ -181,25 +176,18 @@ router.post("/dismiss", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Alert ID is required" }); } - const existingDismissal = await db - .select() - .from(dismissedAlerts) - .where( - and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alertId), - ), + const existingDismissal = + await createCurrentDismissedAlertRepository().findForUser( + userId, + alertId, ); - if (existingDismissal.length > 0) { + if (existingDismissal) { authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); return res.status(409).json({ error: "Alert already dismissed" }); } - await db.insert(dismissedAlerts).values({ - userId, - alertId, - }); + await createCurrentDismissedAlertRepository().create(userId, alertId); res.json({ message: "Alert dismissed successfully" }); } catch (error) { @@ -226,13 +214,8 @@ router.get("/dismissed", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - const dismissedAlertRecords = await db - .select({ - alertId: dismissedAlerts.alertId, - dismissedAt: dismissedAlerts.dismissedAt, - }) - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); + const dismissedAlertRecords = + await createCurrentDismissedAlertRepository().listByUserId(userId); res.json({ dismissed_alerts: dismissedAlertRecords, @@ -280,16 +263,12 @@ router.delete("/dismiss", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Alert ID is required" }); } - const result = await db - .delete(dismissedAlerts) - .where( - and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alertId), - ), - ); + const deleted = await createCurrentDismissedAlertRepository().deleteForUser( + userId, + alertId, + ); - if (result.changes === 0) { + if (!deleted) { return res.status(404).json({ error: "Dismissed alert not found" }); } res.json({ message: "Alert undismissed successfully" }); diff --git a/src/backend/database/routes/audit-log-routes.ts b/src/backend/database/routes/audit-log-routes.ts index 0ba74eb0..91273b91 100644 --- a/src/backend/database/routes/audit-log-routes.ts +++ b/src/backend/database/routes/audit-log-routes.ts @@ -1,10 +1,17 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq, and, gte, lte, sql } from "drizzle-orm"; -import { db } from "../db/index.js"; -import { auditLogs, users } from "../db/schema.js"; +import { + createCurrentAuditLogRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; import { apiLogger } from "../../utils/logger.js"; +async function isAdminUser(userId: string | undefined): Promise { + if (!userId) return false; + const user = await createCurrentUserRepository().findById(userId); + return !!user?.isAdmin; +} + export function registerAuditLogRoutes( router: Router, authenticateJWT: RequestHandler, @@ -53,13 +60,7 @@ export function registerAuditLogRoutes( router.get("/audit-logs", authenticateJWT, async (req, res) => { try { const authReq = req as AuthenticatedRequest; - const adminUser = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, authReq.userId)) - .limit(1); - - if (!adminUser[0]?.isAdmin) { + if (!(await isAdminUser(authReq.userId))) { return res.status(403).json({ error: "Not authorized" }); } @@ -73,36 +74,21 @@ export function registerAuditLogRoutes( const { userId, action, resourceType, success, startDate, endDate } = req.query as Record; - const conditions = []; - - if (userId) conditions.push(eq(auditLogs.userId, userId)); - if (action) conditions.push(eq(auditLogs.action, action)); - if (resourceType) - conditions.push(eq(auditLogs.resourceType, resourceType)); - if (success !== undefined && success !== "") { - conditions.push(eq(auditLogs.success, success === "true")); - } - if (startDate) conditions.push(gte(auditLogs.timestamp, startDate)); - if (endDate) conditions.push(lte(auditLogs.timestamp, endDate)); - - const whereClause = - conditions.length > 0 ? and(...conditions) : undefined; - - const [logs, totalResult] = await Promise.all([ - db - .select() - .from(auditLogs) - .where(whereClause) - .orderBy(sql`${auditLogs.timestamp} DESC`) - .limit(limit) - .offset(offset), - db - .select({ count: sql`COUNT(*)` }) - .from(auditLogs) - .where(whereClause), - ]); - - const total = totalResult[0]?.count ?? 0; + const { logs, total } = await createCurrentAuditLogRepository().listPage({ + filters: { + userId, + action, + resourceType, + success: + success !== undefined && success !== "" + ? success === "true" + : undefined, + startDate, + endDate, + }, + limit, + offset, + }); const totalPages = Math.ceil(total / limit); return res.json({ logs, total, page, totalPages }); @@ -131,21 +117,14 @@ export function registerAuditLogRoutes( router.get("/audit-logs/actions", authenticateJWT, async (req, res) => { try { const authReq = req as AuthenticatedRequest; - const adminUser = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, authReq.userId)) - .limit(1); - - if (!adminUser[0]?.isAdmin) { + if (!(await isAdminUser(authReq.userId))) { return res.status(403).json({ error: "Not authorized" }); } - const rows = db.$client - .prepare("SELECT DISTINCT action FROM audit_logs ORDER BY action ASC") - .all() as { action: string }[]; + const actions = + await createCurrentAuditLogRepository().listDistinctActions(); - return res.json({ actions: rows.map((r) => r.action) }); + return res.json({ actions }); } catch (err) { apiLogger.error("Failed to fetch audit log actions", err); return res diff --git a/src/backend/database/routes/c2s-tunnel-presets.ts b/src/backend/database/routes/c2s-tunnel-presets.ts index 7bca59e4..f11d5da6 100644 --- a/src/backend/database/routes/c2s-tunnel-presets.ts +++ b/src/backend/database/routes/c2s-tunnel-presets.ts @@ -3,12 +3,11 @@ import type { TunnelConnection, } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { c2sTunnelPresets } from "../db/schema.js"; -import { and, asc, eq, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import type { C2sTunnelPresetRecord } from "../repositories/c2s-tunnel-preset-repository.js"; +import { createCurrentC2sTunnelPresetRepository } from "../repositories/factory.js"; const router = express.Router(); @@ -20,7 +19,7 @@ function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } -function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) { +function parsePreset(row: C2sTunnelPresetRecord) { return { ...row, config: JSON.parse(row.config) as TunnelConnection[], @@ -47,6 +46,22 @@ function validateConfig(config: unknown): config is TunnelConnection[] { }); } +/** + * @openapi + * /c2s-tunnel-presets: + * get: + * summary: List client tunnel presets + * description: Returns the authenticated user's saved client-to-server tunnel presets. + * tags: + * - Tunnel Presets + * responses: + * 200: + * description: List of tunnel presets. + * 401: + * description: Authentication required. + * 500: + * description: Failed to list presets. + */ router.get( "/", authenticateJWT, @@ -58,11 +73,8 @@ router.get( } try { - const result = await db - .select() - .from(c2sTunnelPresets) - .where(eq(c2sTunnelPresets.userId, userId)) - .orderBy(asc(c2sTunnelPresets.name)); + const result = + await createCurrentC2sTunnelPresetRepository().listByUserId(userId); res.json(result.map(parsePreset)); } catch (error) { authLogger.error("Failed to fetch C2S tunnel presets", error); @@ -71,6 +83,37 @@ router.get( }, ); +/** + * @openapi + * /c2s-tunnel-presets: + * post: + * summary: Create a client tunnel preset + * description: Saves a named client-to-server tunnel configuration for the authenticated user. + * tags: + * - Tunnel Presets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * config: + * type: array + * items: + * type: object + * responses: + * 200: + * description: Preset created. + * 400: + * description: Invalid name or config. + * 401: + * description: Authentication required. + * 500: + * description: Failed to create preset. + */ router.post( "/", authenticateJWT, @@ -90,36 +133,24 @@ router.post( const trimmedName = name.trim(); try { - const existing = await db - .select() - .from(c2sTunnelPresets) - .where( - and( - eq(c2sTunnelPresets.userId, userId), - eq(c2sTunnelPresets.name, trimmedName), - ), - ); - if (existing.length > 0) { + const presetRepository = createCurrentC2sTunnelPresetRepository(); + if (await presetRepository.hasNameForUser(userId, trimmedName)) { return res.status(409).json({ error: "Preset name already exists" }); } - const result = await db - .insert(c2sTunnelPresets) - .values({ - userId, - name: trimmedName, - config: JSON.stringify(config), - platform: platform?.trim() || null, - computerName: computerName?.trim() || null, - }) - .returning(); + const created = await presetRepository.createForUser(userId, { + name: trimmedName, + config: JSON.stringify(config), + platform: platform?.trim() || null, + computerName: computerName?.trim() || null, + }); databaseLogger.info("C2S tunnel preset created", { operation: "c2s_tunnel_preset_create", userId, - presetId: result[0].id, + presetId: created.id, }); - res.status(201).json(parsePreset(result[0])); + res.status(201).json(parsePreset(created)); } catch (error) { authLogger.error("Failed to create C2S tunnel preset", error); res.status(500).json({ error: "Failed to create C2S tunnel preset" }); @@ -127,6 +158,29 @@ router.post( }, ); +/** + * @openapi + * /c2s-tunnel-presets/{id}: + * put: + * summary: Update a client tunnel preset + * description: Updates the name or config of one of the authenticated user's tunnel presets. + * tags: + * - Tunnel Presets + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: Preset updated. + * 400: + * description: Invalid name or config. + * 404: + * description: Preset not found. + * 500: + * description: Failed to update preset. + */ router.put( "/:id", authenticateJWT, @@ -141,35 +195,21 @@ router.put( } try { - const existing = await db - .select() - .from(c2sTunnelPresets) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); - if (existing.length === 0) { + const presetRepository = createCurrentC2sTunnelPresetRepository(); + const existing = await presetRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Preset not found" }); } - const updateFields: Record = { - updatedAt: sql`CURRENT_TIMESTAMP`, - }; + const updateFields: Parameters[2] = + {}; if (name !== undefined) { if (!isNonEmptyString(name)) { return res.status(400).json({ error: "Preset name is required" }); } const trimmedName = name.trim(); - const duplicate = await db - .select() - .from(c2sTunnelPresets) - .where( - and( - eq(c2sTunnelPresets.userId, userId), - eq(c2sTunnelPresets.name, trimmedName), - ), - ); - if (duplicate.some((preset) => preset.id !== id)) { + if (await presetRepository.hasNameForUser(userId, trimmedName, id)) { return res.status(409).json({ error: "Preset name already exists" }); } updateFields.name = trimmedName; @@ -188,18 +228,12 @@ router.put( if (computerName !== undefined) updateFields.computerName = computerName?.trim() || null; - await db - .update(c2sTunnelPresets) - .set(updateFields) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); - - const updated = await db - .select() - .from(c2sTunnelPresets) - .where(eq(c2sTunnelPresets.id, id)); - res.json(parsePreset(updated[0])); + const updated = await presetRepository.updateForUser( + userId, + id, + updateFields, + ); + res.json(parsePreset(updated ?? existing)); } catch (error) { authLogger.error("Failed to update C2S tunnel preset", error); res.status(500).json({ error: "Failed to update C2S tunnel preset" }); @@ -207,6 +241,27 @@ router.put( }, ); +/** + * @openapi + * /c2s-tunnel-presets/{id}: + * delete: + * summary: Delete a client tunnel preset + * description: Deletes one of the authenticated user's tunnel presets. + * tags: + * - Tunnel Presets + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: Preset deleted. + * 404: + * description: Preset not found. + * 500: + * description: Failed to delete preset. + */ router.delete( "/:id", authenticateJWT, @@ -220,21 +275,13 @@ router.delete( } try { - const existing = await db - .select() - .from(c2sTunnelPresets) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); - if (existing.length === 0) { + const presetRepository = createCurrentC2sTunnelPresetRepository(); + const existing = await presetRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Preset not found" }); } - await db - .delete(c2sTunnelPresets) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); + await presetRepository.deleteForUser(userId, id); res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts index 6d04da0b..1e85386f 100644 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ b/src/backend/database/routes/credential-deploy-routes.ts @@ -3,12 +3,9 @@ import type { CredentialBackend, } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { eq } from "drizzle-orm"; import ssh2Pkg from "ssh2"; -import { db } from "../db/index.js"; -import { hosts, sshCredentials } from "../db/schema.js"; +import { createCurrentHostResolutionRepository } from "../repositories/factory.js"; import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; -import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js"; const { Client } = ssh2Pkg; @@ -87,6 +84,10 @@ async function deploySSHKeyToHost( } const keyPattern = keyParts[1]; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) { + clearTimeout(checkTimeout); + return rejectCheck(new Error("Invalid public key data")); + } conn.exec( `if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`, @@ -192,6 +193,10 @@ async function deploySSHKeyToHost( } const keyPattern = keyParts[1]; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) { + clearTimeout(verifyTimeout); + return rejectVerify(new Error("Invalid public key data")); + } conn.exec( `grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`, (err, stream) => { @@ -265,100 +270,88 @@ async function deploySSHKeyToHost( resolve({ success: false, error: errorMessage }); }); - void (async () => { - try { - const connectionConfig: Record = { - host: hostConfig.ip, - port: hostConfig.port || 22, - username: hostConfig.username, - readyTimeout: 60000, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - algorithms: { - kex: [ - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group1-sha1", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group-exchange-sha1", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", - ], - cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm@openssh.com", - "aes256-gcm@openssh.com", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc", - "3des-cbc", - ], - hmac: [ - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; + try { + const connectionConfig: Record = { + host: hostConfig.ip, + port: hostConfig.port || 22, + username: hostConfig.username, + readyTimeout: 60000, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; - if (hostConfig.authType === "password" && hostConfig.password) { - connectionConfig.password = hostConfig.password; - } else if (hostConfig.authType === "key" && hostConfig.privateKey) { - try { - const privateKey = hostConfig.privateKey as string; - connectionConfig.privateKey = preparePrivateKeyForSSH2( - privateKey, - hostConfig.keyPassword as string | undefined, - ); - - if (hostConfig.keyPassword) { - connectionConfig.passphrase = hostConfig.keyPassword; - } - } catch (keyError) { - clearTimeout(connectionTimeout); - resolve({ - success: false, - error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, - }); - return; - } - } else if (hostConfig.authType === "agent") { - const result = await applyAgentAuth( - connectionConfig, - hostConfig.terminalConfig as Record | undefined, + if (hostConfig.authType === "password" && hostConfig.password) { + connectionConfig.password = hostConfig.password; + } else if (hostConfig.authType === "key" && hostConfig.privateKey) { + try { + const privateKey = hostConfig.privateKey as string; + connectionConfig.privateKey = preparePrivateKeyForSSH2( + privateKey, + hostConfig.keyPassword as string | undefined, ); - if ("error" in result) { - clearTimeout(connectionTimeout); - resolve({ success: false, error: result.error }); - return; + + if (hostConfig.keyPassword) { + connectionConfig.passphrase = hostConfig.keyPassword; } - } else { + } catch (keyError) { clearTimeout(connectionTimeout); resolve({ success: false, - error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, + error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, }); return; } - - conn.connect(connectionConfig); - } catch (error) { + } else { clearTimeout(connectionTimeout); resolve({ success: false, - error: error instanceof Error ? error.message : "Connection failed", + error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, }); + return; } - })(); + + conn.connect(connectionConfig); + } catch (error) { + clearTimeout(connectionTimeout); + resolve({ + success: false, + error: error instanceof Error ? error.message : "Connection failed", + }); + } }); } @@ -427,25 +420,20 @@ export function registerCredentialDeployRoutes( }); } - const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); - const credential = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, credentialId)) - .limit(1), - "ssh_credentials", + const repository = createCurrentHostResolutionRepository(); + const credential = await repository.findCredentialByIdForUser( + credentialId, userId, ); - if (!credential || credential.length === 0) { + if (!credential) { return res.status(404).json({ success: false, error: "Credential not found", }); } - const credData = credential[0] as unknown as CredentialBackend; + const credData = credential as unknown as CredentialBackend; if (credData.authType !== "key") { return res.status(400).json({ @@ -461,21 +449,18 @@ export function registerCredentialDeployRoutes( error: "Public key is required for deployment", }); } - const targetHost = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1), - "ssh_data", + const hostData = await repository.findHostByIdForUser( + targetHostId, userId, ); - if (!targetHost || targetHost.length === 0) { + if (!hostData) { return res.status(404).json({ success: false, error: "Target host not found", }); } - const hostData = targetHost[0]; - const hostConfig = { ip: hostData.ip, port: hostData.port, @@ -484,7 +469,6 @@ export function registerCredentialDeployRoutes( password: hostData.password, privateKey: hostData.key, keyPassword: hostData.keyPassword, - terminalConfig: hostData.terminalConfig, }; if (hostData.authType === "credential" && hostData.credentialId) { @@ -497,29 +481,21 @@ export function registerCredentialDeployRoutes( } try { - const { SimpleDBOps } = - await import("../../utils/simple-db-ops.js"); - const hostCredential = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, hostData.credentialId as number)) - .limit(1), - "ssh_credentials", + const hostCredential = await repository.findCredentialByIdForUser( + hostData.credentialId as number, userId, ); - if (hostCredential && hostCredential.length > 0) { - const cred = hostCredential[0]; + if (hostCredential) { + hostConfig.authType = hostCredential.authType; + hostConfig.username = hostCredential.username; - hostConfig.authType = cred.authType; - hostConfig.username = cred.username; - - if (cred.authType === "password") { - hostConfig.password = cred.password; - } else if (cred.authType === "key") { - hostConfig.privateKey = cred.privateKey || cred.key; - hostConfig.keyPassword = cred.keyPassword; + if (hostCredential.authType === "password") { + hostConfig.password = hostCredential.password; + } else if (hostCredential.authType === "key") { + hostConfig.privateKey = + hostCredential.privateKey || hostCredential.key; + hostConfig.keyPassword = hostCredential.keyPassword; } } else { return res.status(400).json({ diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index e8d767a3..a467426a 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1,21 +1,18 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - sshCredentials, - sshCredentialUsage, - hosts, - hostAccess, -} from "../db/schema.js"; -import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentCredentialRepository, + createCurrentHostResolutionRepository, + createCurrentHostRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; const router = express.Router(); @@ -27,6 +24,11 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); +async function getAuditUsername(userId: string): Promise { + const actor = await createCurrentUserRepository().findById(userId); + return actor?.username ?? userId; +} + /** * @openapi * /credentials: @@ -137,7 +139,8 @@ router.post( .status(400) .json({ error: "SSH key is required for key authentication" }); } - const plainPassword = password ? password : null; + const plainPassword = + authType === "password" && password ? password : null; const plainKey = authType === "key" && key ? key : null; const plainKeyPassword = authType === "key" && keyPassword ? keyPassword : null; @@ -181,23 +184,16 @@ router.post( lastUsed: null, }; - const created = (await SimpleDBOps.insert( - sshCredentials, - "ssh_credentials", - credentialData, - userId, - )) as typeof credentialData & { id: number }; + const created = + await createCurrentCredentialRepository().createEncryptedForUser( + userId, + credentialData, + ); const { ipAddress: ccIp, userAgent: ccUa } = getRequestMeta(req); - const { users: usersTableCc } = await import("../db/schema.js"); - const ccActor = await db - .select({ username: usersTableCc.username }) - .from(usersTableCc) - .where(eq(usersTableCc.id, userId)) - .limit(1); await logAudit({ userId, - username: ccActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "create_credential", resourceType: "credential", resourceId: String(created.id), @@ -265,15 +261,8 @@ router.get( } try { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .orderBy(desc(sshCredentials.updatedAt)), - "ssh_credentials", - userId, - ); + const credentials = + await createCurrentCredentialRepository().listDecryptedByUserId(userId); res.json(credentials.map((cred) => formatCredentialOutput(cred))); } catch (err) { @@ -312,22 +301,7 @@ router.get( } try { - const result = await db - .select({ folder: sshCredentials.folder }) - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); - - const folderCounts: Record = {}; - result.forEach((r) => { - if (r.folder && r.folder.trim() !== "") { - folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; - } - }); - - const folders = Object.keys(folderCounts).filter( - (folder) => folderCounts[folder] > 0, - ); - res.json(folders); + res.json(await createCurrentCredentialRepository().listFolders(userId)); } catch (err) { authLogger.error("Failed to fetch credential folders", err); res.status(500).json({ error: "Failed to fetch credential folders" }); @@ -373,25 +347,16 @@ router.get( } try { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const credential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + parseInt(id), + ); - if (credentials.length === 0) { + if (!credential) { return res.status(404).json({ error: "Credential not found" }); } - const credential = credentials[0]; const output = formatCredentialOutput(credential); if (credential.password) { @@ -468,25 +433,22 @@ router.put( authLogger.warn("Invalid request for credential update"); return res.status(400).json({ error: "Invalid request" }); } + const credentialId = parseInt(id); authLogger.info("Updating SSH credential", { operation: "credential_update", userId, - credentialId: parseInt(id), + credentialId, changes: Object.keys(updateData), }); try { - const existing = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), + const existingCredential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, ); - if (existing.length === 0) { + if (!existingCredential) { return res.status(404).json({ error: "Credential not found" }); } @@ -513,17 +475,16 @@ router.put( if (updateData.password !== undefined) { updateFields.password = updateData.password || null; } - const nextAuthType = updateData.authType ?? existing[0].authType; if (updateData.key !== undefined) { updateFields.key = updateData.key || null; - if (updateData.key && nextAuthType === "key") { + if (updateData.key && existingCredential.authType === "key") { const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword); if (!keyInfo.success) { authLogger.warn("SSH key parsing failed during update", { operation: "credential_update", userId, - credentialId: parseInt(id), + credentialId, error: keyInfo.error, }); return res.status(400).json({ @@ -545,72 +506,49 @@ router.put( } if (Object.keys(updateFields).length === 0) { - const existing = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))), - "ssh_credentials", - userId, - ); - - return res.json(formatCredentialOutput(existing[0])); + return res.json(formatCredentialOutput(existingCredential)); } - await SimpleDBOps.update( - sshCredentials, - "ssh_credentials", - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), + const credentialRepository = createCurrentCredentialRepository(); + const updated = await credentialRepository.updateEncryptedForUser( + userId, + credentialId, updateFields, - userId, ); + const updatedCredential = + updated ?? + (await credentialRepository.findDecryptedByIdForUser( + userId, + credentialId, + )); - const updated = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))), - "ssh_credentials", - userId, - ); - - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.updateSharedCredentialsForOriginal( - parseInt(id), + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().resyncHostsForCredential( + credentialId, userId, ); authLogger.success("SSH credential updated", { operation: "credential_update_success", userId, - credentialId: parseInt(id), + credentialId, }); const { ipAddress: cuIp, userAgent: cuUa } = getRequestMeta(req); - const { users: usersTableCu } = await import("../db/schema.js"); - const cuActor = await db - .select({ username: usersTableCu.username }) - .from(usersTableCu) - .where(eq(usersTableCu.id, userId)) - .limit(1); await logAudit({ userId, - username: cuActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "update_credential", resourceType: "credential", resourceId: id, - resourceName: existing[0].name, + resourceName: String(existingCredential.name ?? id), ipAddress: cuIp, userAgent: cuUa, success: true, }); - res.json(formatCredentialOutput(updated[0])); + res.json(formatCredentialOutput(updatedCredential ?? existingCredential)); } catch (err) { authLogger.error("Failed to update credential", err); res.status(500).json({ @@ -664,96 +602,67 @@ router.delete( }); try { - const credentialToDelete = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), + const credentialId = parseInt(id); + const credentialToDelete = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, ); - if (credentialToDelete.length === 0) { + if (!credentialToDelete) { return res.status(404).json({ error: "Credential not found" }); } - const hostsUsingCredential = await db - .select() - .from(hosts) - .where( - and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)), + const hostsUsingCredential = + await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser( + userId, + credentialId, ); if (hostsUsingCredential.length > 0) { - await db - .update(hosts) - .set({ + await createCurrentHostRepository().updateManyForUser( + userId, + hostsUsingCredential.map((host) => host.id), + { credentialId: null, password: null, key: null, keyPassword: null, authType: "password", - }) - .where( - and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)), - ); - - for (const host of hostsUsingCredential) { - const revokedShares = await db - .delete(hostAccess) - .where(eq(hostAccess.hostId, host.id)) - .returning({ id: hostAccess.id }); - - if (revokedShares.length > 0) { - authLogger.info( - "Auto-revoked host shares due to credential deletion", - { - operation: "auto_revoke_shares", - hostId: host.id, - credentialId: parseInt(id), - revokedCount: revokedShares.length, - reason: "credential_deleted", - }, - ); - } - } + }, + ); } - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id)); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedSecretsManager = SharedHostSecretsManager.getInstance(); + await sharedSecretsManager.deleteForCredential(credentialId); - await db - .delete(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), - ); + await createCurrentCredentialRepository().deleteForUser( + userId, + credentialId, + ); + + // Shares stay in place; re-snapshot so recipients fall back to whatever + // auth the host still has (or lose the stale credential copy). + for (const host of hostsUsingCredential) { + await sharedSecretsManager.resyncHost(host.id); + } authLogger.success("SSH credential deleted", { operation: "credential_delete_success", userId, - credentialId: parseInt(id), + credentialId, }); const { ipAddress: cdIp, userAgent: cdUa } = getRequestMeta(req); - const { users: usersTableCd } = await import("../db/schema.js"); - const cdActor = await db - .select({ username: usersTableCd.username }) - .from(usersTableCd) - .where(eq(usersTableCd.id, userId)) - .limit(1); await logAudit({ userId, - username: cdActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "delete_credential", resourceType: "credential", resourceId: id, - resourceName: credentialToDelete[0].name, + resourceName: String(credentialToDelete.name ?? id), ipAddress: cdIp, userAgent: cdUa, success: true, @@ -817,29 +726,20 @@ router.post( } try { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(credentialId)), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const credential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + parseInt(credentialId), + ); - if (credentials.length === 0) { + if (!credential) { return res.status(404).json({ error: "Credential not found" }); } - const credential = credentials[0]; - - await db - .update(hosts) - .set({ + await createCurrentHostRepository().updateForUser( + userId, + parseInt(hostId), + { credentialId: parseInt(credentialId), username: (credential.username as string) || "", authType: credential.authType as string, @@ -848,24 +748,14 @@ router.post( keyPassword: null, keyType: null, updatedAt: new Date().toISOString(), - }) - .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))); + }, + ); - await db.insert(sshCredentialUsage).values({ - credentialId: parseInt(credentialId), - hostId: parseInt(hostId), + await createCurrentCredentialRepository().recordUsage( userId, - }); - - await db - .update(sshCredentials) - .set({ - usageCount: sql`${sshCredentials.usageCount} - + 1`, - lastUsed: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - .where(eq(sshCredentials.id, parseInt(credentialId))); + parseInt(credentialId), + parseInt(hostId), + ); res.json({ message: "Credential applied to host successfully" }); } catch (err) { authLogger.error("Failed to apply credential to host", err); @@ -916,14 +806,10 @@ router.get( } try { - const hostsUsingCredential = await db - .select() - .from(hosts) - .where( - and( - eq(hosts.credentialId, parseInt(credentialId)), - eq(hosts.userId, userId), - ), + const hostsUsingCredential = + await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser( + userId, + parseInt(credentialId), ); res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host))); @@ -1044,15 +930,11 @@ router.put( } try { - await db - .update(sshCredentials) - .set({ folder: newName }) - .where( - and( - eq(sshCredentials.userId, userId), - eq(sshCredentials.folder, oldName), - ), - ); + await createCurrentCredentialRepository().renameFolder( + userId, + oldName, + newName, + ); res.json({ success: true, message: "Folder renamed successfully" }); } catch (error) { diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts index a8909a3d..34829356 100644 --- a/src/backend/database/routes/dashboard-service-links-routes.ts +++ b/src/backend/database/routes/dashboard-service-links-routes.ts @@ -1,18 +1,22 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; -import { and, asc, eq } from "drizzle-orm"; import { dashboardLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { dashboardServiceLinks } from "../db/schema.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { isNonEmptyString } from "./host-normalizers.js"; -import { - isValidServiceLinkUrl, - normalizeServiceLinkUrl, -} from "./service-link-url.js"; import express from "express"; +import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js"; export const dashboardServiceLinksRouter = express.Router(); +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + /** * @openapi * /service-links: @@ -30,11 +34,8 @@ export const dashboardServiceLinksRouter = express.Router(); dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const links = await db - .select() - .from(dashboardServiceLinks) - .where(eq(dashboardServiceLinks.userId, userId)) - .orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id)); + const links = + await createCurrentDashboardServiceLinkRepository().listByUserId(userId); res.json(links); } catch (err) { dashboardLogger.error("Failed to fetch service links", err); @@ -79,34 +80,23 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { if (!isNonEmptyString(label) || !isNonEmptyString(url)) { return res.status(400).json({ error: "label and url are required" }); } - const normalizedUrl = normalizeServiceLinkUrl(url); - if (!isValidServiceLinkUrl(normalizedUrl)) { + if (!isValidUrl(url)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); } try { - const existing = await db - .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 db - .insert(dashboardServiceLinks) - .values({ + const created = + await createCurrentDashboardServiceLinkRepository().createForUser( userId, - label: label.trim(), - url: normalizedUrl, - order: nextOrder, - createdAt: new Date().toISOString(), - }) - .returning(); + { + label: label.trim(), + url: url.trim(), + }, + ); + DatabaseSaveTrigger.triggerSave("dashboard_service_link_created"); res.status(201).json(created); } catch (err) { dashboardLogger.error("Failed to create service link", err); @@ -152,29 +142,22 @@ dashboardServiceLinksRouter.delete( } try { - const existing = await db - .select() - .from(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), + const existing = + await createCurrentDashboardServiceLinkRepository().findByIdForUser( + userId, + id, ); - if (existing.length === 0) { + if (!existing) { return res.status(404).json({ error: "Not found" }); } - await db - .delete(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ); + await createCurrentDashboardServiceLinkRepository().deleteForUser( + userId, + id, + ); + DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted"); res.json({ message: "Service link deleted" }); } catch (err) { dashboardLogger.error("Failed to delete service link", err); @@ -229,49 +212,39 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { if (isNaN(id)) { return res.status(400).json({ error: "Invalid id" }); } - const normalizedUrl = isNonEmptyString(url) - ? normalizeServiceLinkUrl(url) - : undefined; - if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) { + if (url !== undefined && !isValidUrl(url)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); } try { - const existing = await db - .select() - .from(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), + const existing = + await createCurrentDashboardServiceLinkRepository().findByIdForUser( + userId, + id, ); - if (existing.length === 0) { + if (!existing) { return res.status(404).json({ error: "Not found" }); } const updates: Partial<{ label: string; url: string }> = {}; if (isNonEmptyString(label)) updates.label = label.trim(); - if (normalizedUrl !== undefined) updates.url = normalizedUrl; + if (isNonEmptyString(url)) updates.url = url.trim(); if (Object.keys(updates).length === 0) { return res.status(400).json({ error: "Nothing to update" }); } - const [updated] = await db - .update(dashboardServiceLinks) - .set(updates) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ) - .returning(); + const updated = + await createCurrentDashboardServiceLinkRepository().updateForUser( + userId, + id, + updates, + ); + DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated"); res.json(updated); } catch (err) { dashboardLogger.error("Failed to update service link", err); diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index 9367d3fd..b3ec33e1 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -1,90 +1,100 @@ -import { eq } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; import { - auditLogs, - commandHistory, - dismissedAlerts, - fileManagerPinned, - fileManagerRecent, - fileManagerShortcuts, - hostAccess, - hosts, - networkTopology, - opksshTokens, - recentActivity, - sessionRecordings, - sessions, - sharedCredentials, - snippetFolders, - snippets, - sshCredentialUsage, - sshCredentials, - sshFolders, - transferRecent, - userOpenTabs, - userPreferences, - userRoles, - users, -} from "../db/schema.js"; + createCurrentAlertRepository, + createCurrentApiKeyRepository, + createCurrentAuditLogRepository, + createCurrentC2sTunnelPresetRepository, + createCurrentCommandHistoryRepository, + createCurrentCredentialRepository, + createCurrentDashboardServiceLinkRepository, + createCurrentDismissedAlertRepository, + createCurrentFileManagerBookmarkRepository, + createCurrentHomepageItemRepository, + createCurrentHomepageLayoutRepository, + createCurrentHostHealthRepository, + createCurrentHostFolderRepository, + createCurrentHostMetricsPreferenceRepository, + createCurrentHostRepository, + createCurrentNetworkTopologyRepository, + createCurrentOpksshTokenRepository, + createCurrentOpenTabRepository, + createCurrentRecentActivityRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentSessionRepository, + createCurrentSessionRecordingRepository, + createCurrentSettingsRepository, + createCurrentSharedHostSecretsRepository, + createCurrentSnippetRepository, + createCurrentSshCredentialUsageRepository, + createCurrentTermixIdentityCaRepository, + createCurrentTermixIdentityRepository, + createCurrentTmuxSessionTagRepository, + createCurrentTrustedDeviceRepository, + createCurrentUserPreferenceRepository, + createCurrentUserRepository, + createCurrentTransferRecentRepository, + createCurrentVaultProfileRepository, + createCurrentVaultTokenRepository, +} from "../repositories/factory.js"; export async function deleteUserAndRelatedData(userId: string): Promise { try { - await db - .delete(sharedCredentials) - .where(eq(sharedCredentials.targetUserId, userId)); + await createCurrentSharedHostSecretsRepository().deleteByTargetUserId( + userId, + ); - await db - .delete(sessionRecordings) - .where(eq(sessionRecordings.userId, userId)); + await createCurrentSessionRecordingRepository().deleteByUserId(userId); - await db.delete(hostAccess).where(eq(hostAccess.userId, userId)); - await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId)); + await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences( + userId, + ); - await db.delete(sessions).where(eq(sessions.userId, userId)); + await createCurrentSessionRepository().revokeAllForUser(userId); + await createCurrentApiKeyRepository().deleteByUserId(userId); + await createCurrentTrustedDeviceRepository().deleteByUserId(userId); - await db.delete(userRoles).where(eq(userRoles.userId, userId)); - await db.delete(auditLogs).where(eq(auditLogs.userId, userId)); + await createCurrentRoleRepository().removeAllRolesFromUser(userId); + await createCurrentAlertRepository().deleteByUserId(userId); + await createCurrentAuditLogRepository().deleteByUserId(userId); - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)); + await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)); + await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId); - await db.delete(transferRecent).where(eq(transferRecent.userId, userId)); + await createCurrentTransferRecentRepository().deleteByUserId(userId); - await db.delete(recentActivity).where(eq(recentActivity.userId, userId)); - await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId)); + await createCurrentRecentActivityRepository().deleteByUserId(userId); + await createCurrentDismissedAlertRepository().deleteByUserId(userId); - await db.delete(snippets).where(eq(snippets.userId, userId)); - await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId)); + await createCurrentSnippetRepository().deleteByUserId(userId); - await db.delete(sshFolders).where(eq(sshFolders.userId, userId)); + await createCurrentHostFolderRepository().deleteByUserId(userId); - await db.delete(commandHistory).where(eq(commandHistory.userId, userId)); + await createCurrentCommandHistoryRepository().deleteByUserId(userId); - await db.delete(hosts).where(eq(hosts.userId, userId)); - await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId)); + await createCurrentHostHealthRepository().deleteByUserId(userId); + await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId); + await createCurrentHostRepository().deleteByUserId(userId); + await createCurrentCredentialRepository().deleteByUserId(userId); - await db.delete(networkTopology).where(eq(networkTopology.userId, userId)); - await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId)); - await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)); - await db.delete(userPreferences).where(eq(userPreferences.userId, userId)); + await createCurrentNetworkTopologyRepository().deleteByUserId(userId); + await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId); + await createCurrentHomepageItemRepository().deleteByUserId(userId); + await createCurrentHomepageLayoutRepository().deleteByUserId(userId); + await createCurrentC2sTunnelPresetRepository().deleteByUserId(userId); + await createCurrentOpksshTokenRepository().deleteByUserId(userId); + await createCurrentVaultTokenRepository().deleteByUserId(userId); + await createCurrentVaultProfileRepository().deleteByUserId(userId); + await createCurrentTermixIdentityCaRepository().deleteByUserId(userId); + await createCurrentTermixIdentityRepository().deleteByUserId(userId); + await createCurrentTmuxSessionTagRepository().deleteByUserId(userId); + await createCurrentOpenTabRepository().deleteByUserId(userId); + await createCurrentUserPreferenceRepository().deleteByUserId(userId); - db.$client - .prepare("DELETE FROM settings WHERE key LIKE ?") - .run(`user_%_${userId}`); + await createCurrentSettingsRepository().deleteLike(`user_%_${userId}`); - await db.delete(users).where(eq(users.id, userId)); + await createCurrentUserRepository().delete(userId); authLogger.success("User and all related data deleted successfully", { operation: "delete_user_and_related_data_complete", diff --git a/src/backend/database/routes/donation-modal-utils.ts b/src/backend/database/routes/donation-modal-utils.ts new file mode 100644 index 00000000..f0f85e9d --- /dev/null +++ b/src/backend/database/routes/donation-modal-utils.ts @@ -0,0 +1,12 @@ +const DONATION_MODAL_DELAY_MS = 30 * 24 * 60 * 60 * 1000; + +export function shouldShowDonationModal( + registeredAt: string, + donationModalDismissed: boolean, + now: number = Date.now(), +): boolean { + if (donationModalDismissed) return false; + const registeredAtMs = Date.parse(registeredAt); + if (Number.isNaN(registeredAtMs)) return false; + return now - registeredAtMs >= DONATION_MODAL_DELAY_MS; +} diff --git a/src/backend/database/routes/homepage-items-routes.ts b/src/backend/database/routes/homepage-items-routes.ts index a38a6c1f..5ea49791 100644 --- a/src/backend/database/routes/homepage-items-routes.ts +++ b/src/backend/database/routes/homepage-items-routes.ts @@ -1,10 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; -import { and, asc, eq } from "drizzle-orm"; import { homepageLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { homepageItems } from "../db/schema.js"; -import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { createCurrentHomepageItemRepository } from "../repositories/factory.js"; import express from "express"; export const homepageItemsRouter = express.Router(); @@ -26,11 +23,8 @@ export const homepageItemsRouter = express.Router(); homepageItemsRouter.get("/", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const items = await db - .select() - .from(homepageItems) - .where(eq(homepageItems.userId, userId)) - .orderBy(asc(homepageItems.id)); + const items = + await createCurrentHomepageItemRepository().listByUserId(userId); res.json(items); } catch (err) { homepageLogger.error("Failed to fetch homepage items", err); @@ -78,18 +72,14 @@ homepageItemsRouter.post("/", async (req: Request, res: Response) => { } try { - const [created] = await db - .insert(homepageItems) - .values({ - userId, + const created = await createCurrentHomepageItemRepository().createForUser( + userId, + { typeId, title: title ?? null, config: config ? JSON.stringify(config) : "{}", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - .returning(); - DatabaseSaveTrigger.triggerSave("homepage_item_created"); + }, + ); res.status(201).json(created); } catch (err) { homepageLogger.error("Failed to create homepage item", err); @@ -140,33 +130,18 @@ homepageItemsRouter.put("/:id", async (req: Request, res: Response) => { const { title, config } = req.body; try { - const existing = await db - .select() - .from(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); - - if (existing.length === 0) { + const itemRepository = createCurrentHomepageItemRepository(); + const existing = await itemRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Not found" }); } - const updates: Partial<{ - title: string | null; - config: string; - updatedAt: string; - }> = { - updatedAt: new Date().toISOString(), - }; + const updates: Parameters[2] = {}; if (title !== undefined) updates.title = title; if (config !== undefined) updates.config = JSON.stringify(config); - const [updated] = await db - .update(homepageItems) - .set(updates) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning(); - - DatabaseSaveTrigger.triggerSave("homepage_item_updated"); - res.json(updated); + const updated = await itemRepository.updateForUser(userId, id, updates); + res.json(updated ?? existing); } catch (err) { homepageLogger.error("Failed to update homepage item", err); res.status(500).json({ error: "Failed to update homepage item" }); @@ -203,20 +178,13 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => { if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const existing = await db - .select() - .from(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); - - if (existing.length === 0) { + const itemRepository = createCurrentHomepageItemRepository(); + const existing = await itemRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Not found" }); } - await db - .delete(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); - - DatabaseSaveTrigger.triggerSave("homepage_item_deleted"); + await itemRepository.deleteForUser(userId, id); res.json({ message: "Homepage item deleted" }); } catch (err) { homepageLogger.error("Failed to delete homepage item", err); diff --git a/src/backend/database/routes/homepage-layout-routes.ts b/src/backend/database/routes/homepage-layout-routes.ts index 322917cb..a02ba5a0 100644 --- a/src/backend/database/routes/homepage-layout-routes.ts +++ b/src/backend/database/routes/homepage-layout-routes.ts @@ -1,11 +1,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; -import { eq } from "drizzle-orm"; import { homepageLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { homepageLayouts } from "../db/schema.js"; -import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import express from "express"; +import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js"; export const homepageLayoutRouter = express.Router(); @@ -26,16 +23,13 @@ export const homepageLayoutRouter = express.Router(); homepageLayoutRouter.get("/", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select() - .from(homepageLayouts) - .where(eq(homepageLayouts.userId, userId)); + const row = + await createCurrentHomepageLayoutRepository().findByUserId(userId); - if (rows.length === 0) { + if (!row) { return res.json(null); } - const row = rows[0]; const parsed = JSON.parse(row.layout || "{}"); res.json({ ...row, layout: parsed }); } catch (err) { @@ -76,32 +70,15 @@ homepageLayoutRouter.put("/", async (req: Request, res: Response) => { const layoutData = req.body; try { - const existing = await db - .select({ id: homepageLayouts.id }) - .from(homepageLayouts) - .where(eq(homepageLayouts.userId, userId)); - const layoutJson = JSON.stringify(layoutData); const now = new Date().toISOString(); - - if (existing.length === 0) { - const [created] = await db - .insert(homepageLayouts) - .values({ userId, layout: layoutJson, updatedAt: now }) - .returning(); - const parsed = JSON.parse(created.layout); - DatabaseSaveTrigger.triggerSave("homepage_layout_saved"); - return res.json({ ...created, layout: parsed }); - } - - const [updated] = await db - .update(homepageLayouts) - .set({ layout: layoutJson, updatedAt: now }) - .where(eq(homepageLayouts.userId, userId)) - .returning(); + const updated = await createCurrentHomepageLayoutRepository().upsertForUser( + userId, + layoutJson, + now, + ); const parsed = JSON.parse(updated.layout); - DatabaseSaveTrigger.triggerSave("homepage_layout_saved"); res.json({ ...updated, layout: parsed }); } catch (err) { homepageLogger.error("Failed to save homepage layout", err); diff --git a/src/backend/database/routes/homepage-proxy-routes.ts b/src/backend/database/routes/homepage-proxy-routes.ts index 72115317..eaf23a1b 100644 --- a/src/backend/database/routes/homepage-proxy-routes.ts +++ b/src/backend/database/routes/homepage-proxy-routes.ts @@ -2,6 +2,8 @@ import type { Request, Response } from "express"; import express from "express"; import https from "https"; import http from "http"; +import { lookup } from "dns/promises"; +import { BlockList, isIP } from "net"; import { homepageLogger } from "../../utils/logger.js"; export const homepageProxyRouter = express.Router(); @@ -15,22 +17,95 @@ const proxyCache = new Map(); const CACHE_SIZE = 50; const FETCH_TIMEOUT_MS = 8000; -function fetchJson(url: string): Promise { +const blockedAddresses = new BlockList(); +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + blockedAddresses.addSubnet(network, prefix, "ipv4"); +} +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["::ffff:0:0", 96], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], +] as const) { + blockedAddresses.addSubnet(network, prefix, "ipv6"); +} + +function isBlockedAddress(address: string): boolean { + const family = isIP(address); + return ( + family === 0 || + blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6") + ); +} + +async function resolvePublicUrl(rawUrl: string): Promise<{ + url: URL; + address: string; +}> { + const url = new URL(rawUrl); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new Error("Invalid URL"); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const addresses = isIP(hostname) + ? [{ address: hostname }] + : await lookup(hostname, { all: true, verbatim: true }); + if ( + addresses.length === 0 || + addresses.some(({ address }) => isBlockedAddress(address)) + ) { + throw new Error("Private destinations are not allowed"); + } + + return { url, address: addresses[0].address }; +} + +async function fetchJson(rawUrl: string): Promise { + const { url, address } = await resolvePublicUrl(rawUrl); return new Promise((resolve, reject) => { - const mod = url.startsWith("https") ? https : http; - const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - try { - const text = Buffer.concat(chunks).toString("utf-8"); - resolve(JSON.parse(text)); - } catch { - reject(new Error("Response is not valid JSON")); - } - }); - res.on("error", reject); - }); + const mod = url.protocol === "https:" ? https : http; + const req = mod.get( + { + protocol: url.protocol, + hostname: address, + port: url.port || undefined, + path: `${url.pathname}${url.search}`, + headers: { Host: url.host }, + servername: url.protocol === "https:" ? url.hostname : undefined, + timeout: FETCH_TIMEOUT_MS, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + try { + const text = Buffer.concat(chunks).toString("utf-8"); + resolve(JSON.parse(text)); + } catch { + reject(new Error("Response is not valid JSON")); + } + }); + res.on("error", reject); + }, + ); req.on("error", reject); req.on("timeout", () => { req.destroy(); diff --git a/src/backend/database/routes/host-autostart-routes.ts b/src/backend/database/routes/host-autostart-routes.ts index e4466509..42aa4276 100644 --- a/src/backend/database/routes/host-autostart-routes.ts +++ b/src/backend/database/routes/host-autostart-routes.ts @@ -1,10 +1,8 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { and, eq, isNotNull, or } from "drizzle-orm"; import { DataCrypto } from "../../utils/data-crypto.js"; import { sshLogger } from "../../utils/logger.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import { hosts } from "../db/schema.js"; +import { createCurrentHostRepository } from "../repositories/factory.js"; type HostAutostartRoutesDeps = { authenticateJWT: RequestHandler; @@ -79,12 +77,13 @@ export function registerHostAutostartRoutes( }); } - const sshConfig = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId))); + const hostRepository = createCurrentHostRepository(); + const config = await hostRepository.findByIdForUser( + userId, + sshConfigId, + ); - if (sshConfig.length === 0) { + if (!config) { sshLogger.warn("SSH config not found for autostart enable", { operation: "autostart_enable_failed", userId, @@ -96,8 +95,6 @@ export function registerHostAutostartRoutes( }); } - const config = sshConfig[0]; - const decryptedConfig = DataCrypto.decryptRecord( "ssh_data", config, @@ -109,6 +106,7 @@ export function registerHostAutostartRoutes( if (config.tunnelConnections) { try { const tunnelConnections = JSON.parse(config.tunnelConnections); + const endpointHosts = await hostRepository.listByUserId(userId); const resolvedConnections = await Promise.all( tunnelConnections.map(async (tunnel: Record) => { @@ -118,11 +116,6 @@ export function registerHostAutostartRoutes( !tunnel.endpointPassword && !tunnel.endpointKey ) { - const endpointHosts = await db - .select() - .from(hosts) - .where(eq(hosts.userId, userId)); - const endpointHost = endpointHosts.find( (h) => h.name === tunnel.endpointHost || @@ -160,25 +153,12 @@ export function registerHostAutostartRoutes( } } - await db - .update(hosts) - .set({ - autostartPassword: decryptedConfig.password || null, - autostartKey: decryptedConfig.key || null, - autostartKeyPassword: decryptedConfig.keyPassword || null, - tunnelConnections: updatedTunnelConnections, - }) - .where(eq(hosts.id, sshConfigId)); - - try { - await DatabaseSaveTrigger.triggerSave(); - } catch (saveError) { - sshLogger.warn("Database save failed after autostart", { - operation: "autostart_db_save_failed", - error: - saveError instanceof Error ? saveError.message : "Unknown error", - }); - } + await hostRepository.updateForUser(userId, sshConfigId, { + autostartPassword: decryptedConfig.password || null, + autostartKey: decryptedConfig.key || null, + autostartKeyPassword: decryptedConfig.keyPassword || null, + tunnelConnections: updatedTunnelConnections, + }); res.json({ message: "AutoStart enabled successfully", @@ -240,14 +220,11 @@ export function registerHostAutostartRoutes( } try { - await db - .update(hosts) - .set({ - autostartPassword: null, - autostartKey: null, - autostartKeyPassword: null, - }) - .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId))); + await createCurrentHostRepository().updateForUser(userId, sshConfigId, { + autostartPassword: null, + autostartKey: null, + autostartKeyPassword: null, + }); res.json({ message: "AutoStart disabled successfully", @@ -285,18 +262,9 @@ export function registerHostAutostartRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const autostartConfigs = await db - .select() - .from(hosts) - .where( - and( - eq(hosts.userId, userId), - or( - isNotNull(hosts.autostartPassword), - isNotNull(hosts.autostartKey), - ), - ), - ); + const autostartConfigs = ( + await createCurrentHostRepository().listByUserId(userId) + ).filter((config) => config.autostartPassword || config.autostartKey); const statusList = autostartConfigs.map((config) => ({ sshConfigId: config.id, diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index b7acef60..2838a4b5 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -1,10 +1,11 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, eq, inArray } from "drizzle-orm"; import { sshLogger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import { hosts, sshCredentials } from "../db/schema.js"; +import { + createCurrentCredentialRepository, + createCurrentHostRepository, + createCurrentHostResolutionRepository, +} from "../repositories/factory.js"; import { isNonEmptyString, isValidPort, @@ -190,15 +191,11 @@ export function registerHostBulkRoutes( } try { - const ownedHosts = await db - .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))); + const hostRepository = createCurrentHostRepository(); + const ownedHosts = await hostRepository.listBulkUpdateState( + userId, + hostIds, + ); const ownedIds = ownedHosts.map((h) => h.id); const unauthorizedIds = hostIds.filter( @@ -236,10 +233,11 @@ export function registerHostBulkRoutes( simpleUpdates.enableProxmox = false; if (Object.keys(simpleUpdates).length > 0) { - await db - .update(hosts) - .set(simpleUpdates) - .where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId))); + await hostRepository.updateManyForUser( + userId, + ownedIds, + simpleUpdates, + ); } if (updates.statsConfig && typeof updates.statsConfig === "object") { @@ -249,10 +247,9 @@ export function registerHostBulkRoutes( ? JSON.parse(host.statsConfig as string) : {}; const merged = { ...existing, ...updates.statsConfig }; - await db - .update(hosts) - .set({ statsConfig: JSON.stringify(merged) }) - .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId))); + await hostRepository.updateForUser(userId, host.id, { + statsConfig: JSON.stringify(merged), + }); } catch { errors.push(`Failed to update statsConfig for host ${host.id}`); } @@ -276,22 +273,20 @@ export function registerHostBulkRoutes( dockerPatterns: existing.dockerPatterns ?? "docker", preferredPrefixes: existing.preferredPrefixes ?? "10., 192.168.", + autoSyncEnabled: existing.autoSyncEnabled ?? false, + syncIntervalMinutes: existing.syncIntervalMinutes ?? 15, + markMissingGuests: existing.markMissingGuests ?? true, }; - await db - .update(hosts) - .set({ - enableProxmox: true, - proxmoxConfig: JSON.stringify(merged), - }) - .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId))); + await hostRepository.updateForUser(userId, host.id, { + enableProxmox: true, + proxmoxConfig: JSON.stringify(merged), + }); } catch { errors.push(`Failed to enable Proxmox for host ${host.id}`); } } } - DatabaseSaveTrigger.triggerSave("bulk_update"); - return res.json({ updated: ownedIds.length, failed: unauthorizedIds.length, @@ -342,16 +337,9 @@ export function registerHostBulkRoutes( }; try { - const existingCredentials = await SimpleDBOps.select< - Record - >( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)), - "ssh_credentials", - userId, - ); + const credentialRepository = createCurrentCredentialRepository(); + const existingCredentials = + await credentialRepository.listDecryptedByUserId(userId); for (const credential of existingCredentials) { addCredentialAlias(credential.name, credential.id as number); @@ -370,9 +358,8 @@ export function registerHostBulkRoutes( } const now = new Date().toISOString(); - const created = await SimpleDBOps.insert( - sshCredentials, - "ssh_credentials", + const created = await credentialRepository.createEncryptedForUser( + userId, { userId, name, @@ -395,7 +382,6 @@ export function registerHostBulkRoutes( createdAt: now, updatedAt: now, }, - userId, ); const createdCredential = created as Record; @@ -410,13 +396,13 @@ export function registerHostBulkRoutes( } let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); if (overwrite) { try { - const allHosts = await SimpleDBOps.select>( - db.select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId( + userId, + ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; @@ -524,23 +510,14 @@ export function registerHostBulkRoutes( hostData.authType === "credential" && hostData.credentialId ) { - const cred = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, hostData.credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .limit(1); + const credentialRepository = createCurrentCredentialRepository(); + const cred = await credentialRepository.findByIdForUser( + userId, + hostData.credentialId, + ); - if (cred.length === 0) { - const fallback = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .limit(1); + if (!cred) { + const fallback = await credentialRepository.listByUserId(userId); if (fallback.length > 0) { hostData.credentialId = fallback[0].id; @@ -675,17 +652,15 @@ export function registerHostBulkRoutes( const existing = existingHostMap?.get(lookupKey); if (existing) { - await SimpleDBOps.update( - hosts, - "ssh_data", - eq(hosts.id, existing.id), - sshDataObj, + await hostRepository.updateEncryptedForUser( userId, + existing.id, + sshDataObj, ); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); - await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId); + await hostRepository.createEncryptedForUser(userId, sshDataObj); results.success++; } } catch (error) { @@ -751,7 +726,7 @@ export function registerHostBulkRoutes( let parsed: SSHConfigHost[]; try { parsed = parseSSHConfig(content); - } catch (err) { + } catch { return res .status(400) .json({ error: "Failed to parse SSH config file" }); @@ -793,13 +768,13 @@ export function registerHostBulkRoutes( }; let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); if (overwrite) { try { - const allHosts = await SimpleDBOps.select>( - db.select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId( + userId, + ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; @@ -883,17 +858,15 @@ export function registerHostBulkRoutes( const existing = existingHostMap?.get(lookupKey); if (existing) { - await SimpleDBOps.update( - hosts, - "ssh_data", - eq(hosts.id, existing.id), - sshDataObj, + await hostRepository.updateEncryptedForUser( userId, + existing.id, + sshDataObj, ); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); - await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId); + await hostRepository.createEncryptedForUser(userId, sshDataObj); results.success++; } } catch (error) { diff --git a/src/backend/database/routes/host-command-history-routes.ts b/src/backend/database/routes/host-command-history-routes.ts index d120322b..0f704044 100644 --- a/src/backend/database/routes/host-command-history-routes.ts +++ b/src/backend/database/routes/host-command-history-routes.ts @@ -1,9 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, desc, eq } from "drizzle-orm"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { commandHistory } from "../db/schema.js"; +import { createCurrentCommandHistoryRepository } from "../repositories/factory.js"; import { isNonEmptyString } from "./host-normalizers.js"; export function registerHostCommandHistoryRoutes( @@ -52,22 +50,13 @@ export function registerHostCommandHistoryRoutes( } try { - const history = await db - .select({ - id: commandHistory.id, - command: commandHistory.command, - }) - .from(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostId), - ), - ) - .orderBy(desc(commandHistory.executedAt)) - .limit(200); + const history = + await createCurrentCommandHistoryRepository().listCommandsForHost( + userId, + hostId, + ); - res.json(history.map((h) => h.command)); + res.json(history); } catch (err) { sshLogger.error("Failed to fetch command history from database", err, { operation: "command_history_fetch", @@ -123,15 +112,11 @@ export function registerHostCommandHistoryRoutes( } try { - await db - .delete(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostId), - eq(commandHistory.command, command), - ), - ); + await createCurrentCommandHistoryRepository().deleteCommandForHost( + userId, + hostId, + command, + ); res.json({ message: "Command deleted from history" }); } catch (err) { diff --git a/src/backend/database/routes/host-enrollment-auth.ts b/src/backend/database/routes/host-enrollment-auth.ts new file mode 100644 index 00000000..572570d1 --- /dev/null +++ b/src/backend/database/routes/host-enrollment-auth.ts @@ -0,0 +1,46 @@ +import type { NextFunction, Request, Response } from "express"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; + +export function applyHostEnrollmentDefaults( + hostData: Record, +): Record { + return { + connectionType: "ssh", + port: 22, + authType: "none", + enableTerminal: true, + enableSsh: true, + ...hostData, + }; +} + +export function requireHostEnrollmentAccessForPath( + req: Request, + res: Response, + next: NextFunction, +): void { + if (req.path !== "/enroll") { + next(); + return; + } + + const authReq = req as AuthenticatedRequest; + if (!authReq.apiKeyId) { + res.status(401).json({ + error: "Host enrollment requires an API key", + code: "API_KEY_REQUIRED", + }); + return; + } + + if (!DataCrypto.canUserAccessData(authReq.userId)) { + res.status(423).json({ + error: "User data is locked. Sign in before enrolling hosts.", + code: "DATA_LOCKED", + }); + return; + } + + next(); +} diff --git a/src/backend/database/routes/host-file-manager-bookmark-routes.ts b/src/backend/database/routes/host-file-manager-bookmark-routes.ts index 9260f51e..a56f299c 100644 --- a/src/backend/database/routes/host-file-manager-bookmark-routes.ts +++ b/src/backend/database/routes/host-file-manager-bookmark-routes.ts @@ -1,13 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, desc, eq } from "drizzle-orm"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { - fileManagerPinned, - fileManagerRecent, - fileManagerShortcuts, -} from "../db/schema.js"; +import { createCurrentFileManagerBookmarkRepository } from "../repositories/factory.js"; import { isNonEmptyString } from "./host-normalizers.js"; export function registerHostFileManagerBookmarkRoutes( @@ -57,17 +51,12 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const recentFiles = await db - .select() - .from(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - ), - ) - .orderBy(desc(fileManagerRecent.lastOpened)) - .limit(20); + const recentFiles = + await createCurrentFileManagerBookmarkRepository().listRecentForHost( + userId, + hostId, + 20, + ); res.json(recentFiles); } catch (err) { @@ -119,31 +108,14 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const existing = await db - .select() - .from(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - eq(fileManagerRecent.path, path), - ), - ); - - if (existing.length > 0) { - await db - .update(fileManagerRecent) - .set({ lastOpened: new Date().toISOString() }) - .where(eq(fileManagerRecent.id, existing[0].id)); - } else { - await db.insert(fileManagerRecent).values({ - userId, + await createCurrentFileManagerBookmarkRepository().upsertRecent( + userId, + { hostId, path, - name: name || path.split("/").pop() || "Unknown", - lastOpened: new Date().toISOString(), - }); - } + name, + }, + ); res.json({ message: "Recent file added" }); } catch (err) { @@ -193,15 +165,10 @@ export function registerHostFileManagerBookmarkRoutes( } try { - await db - .delete(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - eq(fileManagerRecent.path, path), - ), - ); + await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath( + userId, + { hostId, path }, + ); res.json({ message: "Recent file removed" }); } catch (err) { @@ -254,16 +221,11 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const pinnedFiles = await db - .select() - .from(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - ), - ) - .orderBy(desc(fileManagerPinned.pinnedAt)); + const pinnedFiles = + await createCurrentFileManagerBookmarkRepository().listPinnedForHost( + userId, + hostId, + ); res.json(pinnedFiles); } catch (err) { @@ -317,29 +279,16 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const existing = await db - .select() - .from(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - eq(fileManagerPinned.path, path), - ), + const created = + await createCurrentFileManagerBookmarkRepository().createPinned( + userId, + { hostId, path, name }, ); - if (existing.length > 0) { + if (!created) { return res.status(409).json({ error: "File already pinned" }); } - await db.insert(fileManagerPinned).values({ - userId, - hostId, - path, - name: name || path.split("/").pop() || "Unknown", - pinnedAt: new Date().toISOString(), - }); - res.json({ message: "File pinned" }); } catch (err) { sshLogger.error("Failed to pin file", err); @@ -388,15 +337,10 @@ export function registerHostFileManagerBookmarkRoutes( } try { - await db - .delete(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - eq(fileManagerPinned.path, path), - ), - ); + await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath( + userId, + { hostId, path }, + ); res.json({ message: "Pinned file removed" }); } catch (err) { @@ -449,16 +393,11 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const shortcuts = await db - .select() - .from(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - ), - ) - .orderBy(desc(fileManagerShortcuts.createdAt)); + const shortcuts = + await createCurrentFileManagerBookmarkRepository().listShortcutsForHost( + userId, + hostId, + ); res.json(shortcuts); } catch (err) { @@ -512,29 +451,16 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const existing = await db - .select() - .from(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - eq(fileManagerShortcuts.path, path), - ), + const created = + await createCurrentFileManagerBookmarkRepository().createShortcut( + userId, + { hostId, path, name }, ); - if (existing.length > 0) { + if (!created) { return res.status(409).json({ error: "Shortcut already exists" }); } - await db.insert(fileManagerShortcuts).values({ - userId, - hostId, - path, - name: name || path.split("/").pop() || "Unknown", - createdAt: new Date().toISOString(), - }); - res.json({ message: "Shortcut added" }); } catch (err) { sshLogger.error("Failed to add shortcut", err); @@ -583,15 +509,10 @@ export function registerHostFileManagerBookmarkRoutes( } try { - await db - .delete(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - eq(fileManagerShortcuts.path, path), - ), - ); + await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath( + userId, + { hostId, path }, + ); res.json({ message: "Shortcut removed" }); } catch (err) { diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index c6d2da55..dfde1491 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -1,23 +1,16 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { and, eq, inArray, like, or, sql } from "drizzle-orm"; import { databaseLogger, sshLogger } from "../../utils/logger.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import { - commandHistory, - fileManagerPinned, - fileManagerRecent, - fileManagerShortcuts, - hostAccess, - hosts, - recentActivity, - sessionRecordings, - sshCredentialUsage, - sshCredentials, - sshFolders, - transferRecent, -} from "../db/schema.js"; + createCurrentCommandHistoryRepository, + createCurrentFileManagerBookmarkRepository, + createCurrentHostFolderRepository, + createCurrentRecentActivityRepository, + createCurrentRbacAccessRepository, + createCurrentSshCredentialUsageRepository, + createCurrentSessionRecordingRepository, + createCurrentTransferRecentRepository, +} from "../repositories/factory.js"; import { isNonEmptyString } from "./host-normalizers.js"; type HostFolderRoutesDeps = { @@ -75,49 +68,17 @@ export function registerHostFolderRoutes( } try { - const now = new Date().toISOString(); - const oldPrefix = `${oldName} / `; - const newPrefix = `${newName} / `; - const childLike = `${oldPrefix}%`; - - // folder is a plaintext column, so a SQL expression renames the exact - // folder and re-paths every nested child in one statement. - 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 db - .update(hosts) - .set({ folder: renameExpr(hosts.folder), updatedAt: now }) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))) - .returning(); - - const updatedCredentials = await db - .update(sshCredentials) - .set({ folder: renameExpr(sshCredentials.folder), updatedAt: now }) - .where( - and( - eq(sshCredentials.userId, userId), - folderMatch(sshCredentials.folder), - ), - ) - .returning(); - - DatabaseSaveTrigger.triggerSave("folder_rename"); - - await db - .update(sshFolders) - .set({ name: renameExpr(sshFolders.name), updatedAt: now }) - .where( - and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), + const { updatedHosts, updatedCredentials } = + await createCurrentHostFolderRepository().renameFolder( + userId, + oldName, + newName, ); res.json({ message: "Folder renamed successfully", - updatedHosts: updatedHosts.length, - updatedCredentials: updatedCredentials.length, + updatedHosts, + updatedCredentials, }); } catch (err) { sshLogger.error("Failed to rename folder", err, { @@ -158,10 +119,8 @@ export function registerHostFolderRoutes( } try { - const folders = await db - .select() - .from(sshFolders) - .where(eq(sshFolders.userId, userId)); + const folders = + await createCurrentHostFolderRepository().listFolders(userId); res.json(folders); } catch (err) { @@ -215,46 +174,28 @@ export function registerHostFolderRoutes( } try { - const existing = await db - .select() - .from(sshFolders) - .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) - .limit(1); + const { folder, created } = + await createCurrentHostFolderRepository().upsertMetadata( + userId, + name, + color, + icon, + ); - if (existing.length > 0) { + if (!created) { databaseLogger.info("Updating SSH folder", { operation: "folder_update", userId, - folderId: existing[0].id, + folderId: folder.id, }); - await db - .update(sshFolders) - .set({ - color, - icon, - updatedAt: new Date().toISOString(), - }) - .where( - and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), - ); } else { databaseLogger.info("Creating SSH folder", { operation: "folder_create", userId, name, }); - await db.insert(sshFolders).values({ - userId, - name, - color, - icon, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); } - DatabaseSaveTrigger.triggerSave("folder_metadata_update"); - res.json({ message: "Folder metadata updated successfully" }); } catch (err) { sshLogger.error("Failed to update folder metadata", err, { @@ -308,76 +249,48 @@ export function registerHostFolderRoutes( }); try { - // Match the folder itself and any nested children (e.g. "fgh / sub"). - const childLike = `${folderName} / %`; - const folderMatch = (col: SQLiteColumn) => - or(eq(col, folderName), like(col, childLike)); - - const hostsToDelete = await db - .select() - .from(hosts) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); + const hostFolderRepository = createCurrentHostFolderRepository(); + const hostsToDelete = await hostFolderRepository.listHostsInFolder( + userId, + folderName, + ); const hostIds = hostsToDelete.map((host) => host.id); if (hostIds.length > 0) { - await db - .delete(fileManagerRecent) - .where(inArray(fileManagerRecent.hostId, hostIds)); - - await db - .delete(fileManagerPinned) - .where(inArray(fileManagerPinned.hostId, hostIds)); - - await db - .delete(fileManagerShortcuts) - .where(inArray(fileManagerShortcuts.hostId, hostIds)); - - await db - .delete(transferRecent) - .where( - or( - inArray(transferRecent.sourceHostId, hostIds), - inArray(transferRecent.destHostId, hostIds), - ), - ); - - await db - .delete(commandHistory) - .where(inArray(commandHistory.hostId, hostIds)); - - await db - .delete(sshCredentialUsage) - .where(inArray(sshCredentialUsage.hostId, hostIds)); - - await db - .delete(recentActivity) - .where(inArray(recentActivity.hostId, hostIds)); - - await db - .delete(hostAccess) - .where(inArray(hostAccess.hostId, hostIds)); - - await db - .delete(sessionRecordings) - .where(inArray(sessionRecordings.hostId, hostIds)); - } - - if (hostIds.length > 0) { - await db - .delete(hosts) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); - } - - // Always remove the folder records (and nested children), even when the - // folder held no hosts, so empty folders don't reappear on reload. - await db - .delete(sshFolders) - .where( - and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), + await createCurrentFileManagerBookmarkRepository().deleteByHostIds( + hostIds, ); - DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); + await createCurrentTransferRecentRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentCommandHistoryRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentSshCredentialUsageRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentRecentActivityRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentRbacAccessRepository().deleteHostAccessForHosts( + hostIds, + ); + + await createCurrentSessionRecordingRepository().deleteByHostIds( + hostIds, + ); + } + + await hostFolderRepository.deleteHostsAndFolderRecords( + userId, + folderName, + ); try { const axios = (await import("axios")).default; diff --git a/src/backend/database/routes/host-internal-routes.ts b/src/backend/database/routes/host-internal-routes.ts index 32988f61..504e4028 100644 --- a/src/backend/database/routes/host-internal-routes.ts +++ b/src/backend/database/routes/host-internal-routes.ts @@ -1,9 +1,7 @@ import type { Request, Response, Router } from "express"; -import { and, eq, isNotNull } from "drizzle-orm"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { hosts } from "../db/schema.js"; +import { createCurrentHostResolutionRepository } from "../repositories/factory.js"; export function registerHostInternalRoutes(router: Router): void { /** @@ -45,12 +43,8 @@ export function registerHostInternalRoutes(router: Router): void { } try { - const autostartHosts = await db - .select() - .from(hosts) - .where( - and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)), - ); + const autostartHosts = + await createCurrentHostResolutionRepository().listHostsWithTunnelConnections(); const result = autostartHosts .map((host) => { @@ -134,7 +128,8 @@ export function registerHostInternalRoutes(router: Router): void { .json({ error: "Invalid internal authentication token" }); } - const allHosts = await db.select().from(hosts); + const allHosts = + await createCurrentHostResolutionRepository().listAllHosts(); const result = allHosts.map((host) => { const tunnelConnections = host.tunnelConnections diff --git a/src/backend/database/routes/host-network-routes.ts b/src/backend/database/routes/host-network-routes.ts index 654aa7c6..6050efe5 100644 --- a/src/backend/database/routes/host-network-routes.ts +++ b/src/backend/database/routes/host-network-routes.ts @@ -1,10 +1,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, eq } from "drizzle-orm"; import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { hosts } from "../db/schema.js"; +import { createCurrentHostResolutionRepository } from "../repositories/factory.js"; interface HostNetworkRoutesDeps { authenticateJWT: RequestHandler; @@ -100,16 +98,12 @@ export function registerHostNetworkRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const host = await db - .select({ - macAddress: hosts.macAddress, - wolBroadcastAddress: hosts.wolBroadcastAddress, - }) - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .then((rows) => rows[0]); + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); - if (!host) { + if (!host || host.userId !== userId) { return res.status(404).json({ error: "Host not found" }); } diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 9dc4e1a7..fd406e54 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -231,6 +231,76 @@ export function stripSensitiveFields( return result; } +// Connection essentials a connect-level recipient is allowed to see. +const CONNECT_LEVEL_FIELDS = new Set([ + "id", + "userId", + "ownerId", + "ownerUsername", + "isShared", + "permissionLevel", + "sharedExpiresAt", + "name", + "ip", + "port", + "username", + "folder", + "tags", + "pin", + "authType", + "connectionType", + "credentialId", + "enableTerminal", + "enableTunnel", + "enableFileManager", + "enableDocker", + "enableProxmox", + "enableTmuxMonitor", + "showTerminalInSidebar", + "showFileManagerInSidebar", + "showTunnelInSidebar", + "showDockerInSidebar", + "showServerStatsInSidebar", + "enableSsh", + "enableRdp", + "enableVnc", + "enableTelnet", + "sshPort", + "rdpPort", + "vncPort", + "telnetPort", + "defaultPath", + "scpLegacy", + "tunnelConnections", + "jumpHosts", + "createdAt", + "updatedAt", +]); + +/** + * Shapes a shared host row for its recipient. Secrets are always stripped + * (all levels); connect-level recipients are additionally reduced to + * connection essentials since they may not view the host's configuration. + */ +export function sanitizeHostForRecipient( + host: Record, + permissionLevel: string | undefined, +): Record { + const stripped = stripSensitiveFields(host); + + if (permissionLevel !== "connect") { + return stripped; + } + + const reduced: Record = {}; + for (const [key, value] of Object.entries(stripped)) { + if (CONNECT_LEVEL_FIELDS.has(key)) { + reduced[key] = value; + } + } + return reduced; +} + export function transformHostResponse( host: Record, ): Record { diff --git a/src/backend/database/routes/host-opkssh-routes.ts b/src/backend/database/routes/host-opkssh-routes.ts index 75ab5346..8ad2b4f2 100644 --- a/src/backend/database/routes/host-opkssh-routes.ts +++ b/src/backend/database/routes/host-opkssh-routes.ts @@ -52,7 +52,7 @@ export function registerHostOpksshRoutes(router: Router): void { try { const { getActiveAuthSession, registerOAuthState } = - await import("../../ssh/opkssh-auth.js"); + await import("../../hosts/opkssh-auth.js"); const session = getActiveAuthSession(requestId); if (!session) { @@ -562,7 +562,7 @@ export function registerHostOpksshRoutes(router: Router): void { getActiveAuthSession, getRequestIdByOAuthState, clearOAuthState, - } = await import("../../ssh/opkssh-auth.js"); + } = await import("../../hosts/opkssh-auth.js"); const userId = await getUserIdFromRequest({ cookies: req.cookies, @@ -729,7 +729,7 @@ export function registerHostOpksshRoutes(router: Router): void { try { const { getActiveAuthSession } = - await import("../../ssh/opkssh-auth.js"); + await import("../../hosts/opkssh-auth.js"); const session = getActiveAuthSession(requestId); if (!session) { diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 9d38b10d..e4a80ddd 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -1,34 +1,35 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - hosts, - sshCredentials, - sshCredentialUsage, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - transferRecent, - commandHistory, - recentActivity, - hostAccess, - userRoles, - sessionRecordings, -} from "../db/schema.js"; -import { eq, and, or, isNull, gte, sql, inArray, desc } from "drizzle-orm"; import type { Request, Response } from "express"; import axios from "axios"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; -import { pickResolvedUsername } from "../../ssh/credential-username.js"; +import { + pickResolvedPassword, + pickResolvedUsername, +} from "../../hosts/credential-username.js"; +import { + createCurrentCommandHistoryRepository, + createCurrentFileManagerBookmarkRepository, + createCurrentOpksshTokenRepository, + createCurrentRecentActivityRepository, + createCurrentSshCredentialUsageRepository, + createCurrentSessionRecordingRepository, + createCurrentTransferRecentRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentHostResolutionRepository, + createCurrentHostRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; import { isNonEmptyString, isValidPort, + sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, } from "./host-normalizers.js"; @@ -40,6 +41,10 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js"; import { registerHostInternalRoutes } from "./host-internal-routes.js"; import { registerHostNetworkRoutes } from "./host-network-routes.js"; import { registerHostBulkRoutes } from "./host-bulk-routes.js"; +import { + applyHostEnrollmentDefaults, + requireHostEnrollmentAccessForPath, +} from "./host-enrollment-auth.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; const router = express.Router(); @@ -48,6 +53,11 @@ const upload = multer({ storage: multer.memoryStorage() }); const STATS_SERVER_URL = "http://localhost:30005"; +async function getAuditUsername(userId: string): Promise { + const actor = await createCurrentUserRepository().findById(userId); + return actor?.username ?? userId; +} + function notifyStatsHostUpdated( hostId: number, headers: Pick, @@ -79,69 +89,6 @@ const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -type ShareCredentialExport = { - alias: string; - name: string; - authType: "password" | "key"; - username: string | null; - description: string | null; - folder: string | null; - tags: string[]; - keyType: string | null; -}; - -function parseJsonField(value: unknown, fallback: unknown) { - if (!value || typeof value !== "string") return fallback; - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - -function splitTags(value: unknown): string[] { - if (Array.isArray(value)) - return value.filter((tag) => typeof tag === "string"); - if (typeof value !== "string") return []; - return value.split(",").filter(Boolean); -} - -function uniqueAlias(base: string, used: Set): string { - const normalized = - base - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") || "credential"; - let alias = normalized; - let suffix = 2; - while (used.has(alias)) { - alias = `${normalized}-${suffix++}`; - } - used.add(alias); - return alias; -} - -function safeCredentialExport( - credential: Record, - alias: string, -): ShareCredentialExport { - return { - alias, - name: String(credential.name || alias), - authType: credential.authType === "key" ? "key" : "password", - username: - typeof credential.username === "string" ? credential.username : null, - description: - typeof credential.description === "string" - ? credential.description - : null, - folder: typeof credential.folder === "string" ? credential.folder : null, - tags: splitTags(credential.tags), - keyType: typeof credential.keyType === "string" ? credential.keyType : null, - }; -} - registerHostInternalRoutes(router); /** @@ -161,9 +108,10 @@ registerHostInternalRoutes(router); * description: Failed to save SSH data. */ router.post( - "/db/host", + ["/db/host", "/enroll"], authenticateJWT, requireDataAccess, + requireHostEnrollmentAccessForPath, upload.single("key"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -196,6 +144,10 @@ router.post( hostData = req.body; } + if (req.path === "/enroll") { + hostData = applyHostEnrollmentDefaults(hostData); + } + const { connectionType, name, @@ -259,6 +211,8 @@ router.post( rdpPort, vncPort, telnetPort, + rdpAuthType, + rdpCredentialId, rdpUser, rdpPassword, rdpDomain, @@ -268,6 +222,8 @@ router.post( vncCredentialId, vncPassword, vncUser, + telnetAuthType, + telnetCredentialId, telnetUser, telnetPassword, } = hostData; @@ -385,6 +341,11 @@ router.post( rdpPort: rdpPort || 3389, vncPort: vncPort || 5900, telnetPort: telnetPort || 23, + rdpAuthType: enableRdp ? rdpAuthType || null : null, + rdpCredentialId: + enableRdp && rdpAuthType === "credential" && rdpCredentialId + ? rdpCredentialId + : null, rdpUser: rdpUser || null, rdpDomain: rdpDomain || null, rdpSecurity: rdpSecurity || null, @@ -395,6 +356,11 @@ router.post( ? vncCredentialId : null, vncUser: vncUser || null, + telnetAuthType: enableTelnet ? telnetAuthType || null : null, + telnetCredentialId: + enableTelnet && telnetAuthType === "credential" && telnetCredentialId + ? telnetCredentialId + : null, telnetUser: telnetUser || null, }; @@ -433,7 +399,12 @@ router.post( sshDataObj.key = key || null; sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; - sshDataObj.password = null; + sshDataObj.password = password || null; + } else if (effectiveAuthType === "credential") { + sshDataObj.password = password || null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else if (effectiveAuthType === "agent") { sshDataObj.password = null; sshDataObj.key = null; @@ -451,11 +422,9 @@ router.post( sshDataObj.telnetPassword = telnetPassword || null; try { - const result = await SimpleDBOps.insert( - hosts, - "ssh_data", - sshDataObj, + const result = await createCurrentHostRepository().createEncryptedForUser( userId, + sshDataObj, ); if (!result) { @@ -482,15 +451,9 @@ router.post( }); const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req); - const { users: usersTable } = await import("../db/schema.js"); - const chActor = await db - .select({ username: usersTable.username }) - .from(usersTable) - .where(eq(usersTable.id, userId)) - .limit(1); await logAudit({ userId, - username: chActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "create_host", resourceType: "host", resourceId: String(createdHost.id), @@ -520,6 +483,67 @@ router.post( }, ); +/** + * @openapi + * /host/enroll: + * post: + * summary: Enroll a host with an API key + * description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in. + * tags: + * - Host Enrollment + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [ip] + * properties: + * name: + * type: string + * ip: + * type: string + * port: + * type: integer + * minimum: 1 + * maximum: 65535 + * default: 22 + * username: + * type: string + * authType: + * type: string + * enum: [none, password, key, credential, agent] + * default: none + * password: + * type: string + * folder: + * type: string + * tags: + * oneOf: + * - type: string + * - type: array + * items: + * type: string + * enableTerminal: + * type: boolean + * enableFileManager: + * type: boolean + * enableTunnel: + * type: boolean + * responses: + * 200: + * description: Host enrolled successfully. + * 400: + * description: Invalid host data. + * 401: + * description: Missing or invalid API key. + * 423: + * description: The API key user's encrypted data is locked. + * 500: + * description: Failed to enroll the host. + */ /** * @openapi * /host/quick-connect: @@ -622,27 +646,19 @@ router.post( let resolvedUsername = username; if (authType === "credential" && credentialId) { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const cred = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + Number(credentialId), + userId, + ); - if (!credentials || credentials.length === 0) { + if (!cred) { return res.status(404).json({ error: "Credential not found" }); } - const cred = credentials[0]; - - resolvedPassword = cred.password as string | undefined; + resolvedPassword = pickResolvedPassword(password, cred.password) as + | string + | undefined; resolvedKey = cred.privateKey as string | undefined; resolvedKeyPassword = cred.keyPassword as string | undefined; resolvedKeyType = cred.keyType as string | undefined; @@ -835,6 +851,8 @@ router.put( rdpPort, vncPort, telnetPort, + rdpAuthType, + rdpCredentialId, rdpUser, rdpPassword, rdpDomain, @@ -844,6 +862,8 @@ router.put( vncCredentialId, vncPassword, vncUser, + telnetAuthType, + telnetCredentialId, telnetUser, telnetPassword, } = hostData; @@ -958,6 +978,11 @@ router.put( rdpPort: rdpPort || 3389, vncPort: vncPort || 5900, telnetPort: telnetPort || 23, + rdpAuthType: enableRdp ? rdpAuthType || null : null, + rdpCredentialId: + enableRdp && rdpAuthType === "credential" && rdpCredentialId + ? rdpCredentialId + : null, rdpUser: rdpUser || null, rdpDomain: rdpDomain || null, rdpSecurity: rdpSecurity || null, @@ -968,6 +993,11 @@ router.put( ? vncCredentialId : null, vncUser: vncUser || null, + telnetAuthType: enableTelnet ? telnetAuthType || null : null, + telnetCredentialId: + enableTelnet && telnetAuthType === "credential" && telnetCredentialId + ? telnetCredentialId + : null, telnetUser: telnetUser || null, }; @@ -1015,7 +1045,12 @@ router.put( if (keyType) { sshDataObj.keyType = keyType; } - sshDataObj.password = null; + sshDataObj.password = password || null; + } else if (effectiveAuthType === "credential") { + sshDataObj.password = password || null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else if (effectiveAuthType === "agent") { sshDataObj.password = null; sshDataObj.key = null; @@ -1036,7 +1071,7 @@ router.put( const accessInfo = await permissionManager.canAccessHost( userId, Number(hostId), - "write", + "edit", ); if (!accessInfo.hasAccess) { @@ -1048,30 +1083,12 @@ router.put( return res.status(403).json({ error: "Access denied" }); } - if (!accessInfo.isOwner) { - sshLogger.warn("Shared user attempted to update host (view-only)", { - operation: "host_update", - hostId: parseInt(hostId), - userId, - }); - return res.status(403).json({ - error: "Only the host owner can modify host configuration", - }); - } + const hostRecord = + await createCurrentHostResolutionRepository().findHostUpdateState( + Number(hostId), + ); - const hostRecord = await db - .select({ - userId: hosts.userId, - credentialId: hosts.credentialId, - rdpCredentialId: hosts.rdpCredentialId, - vncCredentialId: hosts.vncCredentialId, - authType: hosts.authType, - }) - .from(hosts) - .where(eq(hosts.id, Number(hostId))) - .limit(1); - - if (hostRecord.length === 0) { + if (!hostRecord) { sshLogger.warn("Host not found for update", { operation: "host_update", hostId: parseInt(hostId), @@ -1080,72 +1097,84 @@ router.put( return res.status(404).json({ error: "Host not found" }); } - const ownerId = hostRecord[0].userId; + const ownerId = hostRecord.userId; - if ( - !accessInfo.isOwner && - sshDataObj.credentialId !== undefined && - sshDataObj.credentialId !== hostRecord[0].credentialId - ) { - return res.status(403).json({ - error: "Only the host owner can change the credential", - }); - } + if (!accessInfo.isOwner) { + // Shared editors work on the owner's real record but may never + // repoint it at credential/vault references (those live in the + // owner's personal vault) or switch the authentication type. + const referenceViolations: Array<[unknown, number | null, string]> = [ + [sshDataObj.credentialId, hostRecord.credentialId, "credential"], + [ + sshDataObj.rdpCredentialId, + hostRecord.rdpCredentialId, + "RDP credential", + ], + [ + sshDataObj.vncCredentialId, + hostRecord.vncCredentialId, + "VNC credential", + ], + [ + sshDataObj.telnetCredentialId, + hostRecord.telnetCredentialId, + "Telnet credential", + ], + [ + sshDataObj.vaultProfileId, + hostRecord.vaultProfileId, + "Vault profile", + ], + ]; - if ( - !accessInfo.isOwner && - sshDataObj.authType !== undefined && - sshDataObj.authType !== hostRecord[0].authType - ) { - return res.status(403).json({ - error: "Only the host owner can change the authentication type", - }); - } + for (const [incoming, current, label] of referenceViolations) { + if (incoming !== undefined && (incoming ?? null) !== current) { + return res.status(403).json({ + error: `Only the host owner can change the ${label}`, + }); + } + } - { - const newCredId = - sshDataObj.credentialId !== undefined - ? sshDataObj.credentialId - : hostRecord[0].credentialId; - const newRdpCredId = - sshDataObj.rdpCredentialId !== undefined - ? sshDataObj.rdpCredentialId - : hostRecord[0].rdpCredentialId; - const newVncCredId = - sshDataObj.vncCredentialId !== undefined - ? sshDataObj.vncCredentialId - : hostRecord[0].vncCredentialId; - const hadCredential = - hostRecord[0].credentialId !== null || - hostRecord[0].rdpCredentialId !== null || - hostRecord[0].vncCredentialId !== null; - const willHaveCredential = - newCredId !== null || newRdpCredId !== null || newVncCredId !== null; - if (hadCredential && !willHaveCredential) { - await db - .delete(hostAccess) - .where(eq(hostAccess.hostId, Number(hostId))); + if ( + sshDataObj.authType !== undefined && + sshDataObj.authType !== hostRecord.authType + ) { + return res.status(403).json({ + error: "Only the host owner can change the authentication type", + }); } } - await SimpleDBOps.update( - hosts, - "ssh_data", - eq(hosts.id, Number(hostId)), + await createCurrentHostRepository().updateEncryptedForUser( + ownerId, + Number(hostId), sshDataObj, - ownerId, ); - const updatedHosts = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(eq(hosts.id, Number(hostId))), - "ssh_data", - ownerId, - ); + // Keep every recipient's re-encrypted secret snapshots in sync with + // the updated host record. + try { + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId)); + } catch (resyncError) { + sshLogger.warn("Failed to resync shared host secrets after update", { + operation: "host_update_resync", + hostId: parseInt(hostId), + error: + resyncError instanceof Error + ? resyncError.message + : "Unknown error", + }); + } - if (updatedHosts.length === 0) { + const updatedHost = + await createCurrentHostResolutionRepository().findHostById( + Number(hostId), + ownerId, + ); + + if (!updatedHost) { sshLogger.warn("Updated host not found after update", { operation: "host_update", hostId: parseInt(hostId), @@ -1154,7 +1183,6 @@ router.put( return res.status(404).json({ error: "Host not found after update" }); } - const updatedHost = updatedHosts[0]; const baseHost = transformHostResponse(updatedHost); const resolvedHost = @@ -1166,15 +1194,9 @@ router.put( }); const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req); - const { users: usersTableUpd } = await import("../db/schema.js"); - const uhActor = await db - .select({ username: usersTableUpd.username }) - .from(usersTableUpd) - .where(eq(usersTableUpd.id, userId)) - .limit(1); await logAudit({ userId, - username: uhActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "update_host", resourceType: "host", resourceId: hostId, @@ -1233,130 +1255,19 @@ router.get( try { const now = new Date().toISOString(); - const userRoleIds = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const accessEntries = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, + now, + ); - const rawData = await db - .select({ - id: hosts.id, - userId: hosts.userId, - connectionType: hosts.connectionType, - name: hosts.name, - ip: hosts.ip, - port: hosts.port, - username: hosts.username, - folder: hosts.folder, - tags: hosts.tags, - pin: hosts.pin, - authType: hosts.authType, - password: hosts.password, - key: hosts.key, - keyPassword: hosts.keyPassword, - keyType: hosts.keyType, - enableTerminal: hosts.enableTerminal, - enableTunnel: hosts.enableTunnel, - tunnelConnections: hosts.tunnelConnections, - jumpHosts: hosts.jumpHosts, - enableFileManager: hosts.enableFileManager, - scpLegacy: hosts.scpLegacy, - defaultPath: hosts.defaultPath, - autostartPassword: hosts.autostartPassword, - autostartKey: hosts.autostartKey, - autostartKeyPassword: hosts.autostartKeyPassword, - forceKeyboardInteractive: hosts.forceKeyboardInteractive, - statsConfig: hosts.statsConfig, - terminalConfig: hosts.terminalConfig, - sudoPassword: hosts.sudoPassword, - createdAt: hosts.createdAt, - updatedAt: hosts.updatedAt, - credentialId: hosts.credentialId, - vaultProfileId: hosts.vaultProfileId, - overrideCredentialUsername: hosts.overrideCredentialUsername, - quickActions: hosts.quickActions, - notes: hosts.notes, - enableDocker: hosts.enableDocker, - enableProxmox: hosts.enableProxmox, - enableTmuxMonitor: hosts.enableTmuxMonitor, - showTerminalInSidebar: hosts.showTerminalInSidebar, - showFileManagerInSidebar: hosts.showFileManagerInSidebar, - showTunnelInSidebar: hosts.showTunnelInSidebar, - showDockerInSidebar: hosts.showDockerInSidebar, - showServerStatsInSidebar: hosts.showServerStatsInSidebar, - useSocks5: hosts.useSocks5, - socks5Host: hosts.socks5Host, - socks5Port: hosts.socks5Port, - socks5Username: hosts.socks5Username, - socks5Password: hosts.socks5Password, - socks5ProxyChain: hosts.socks5ProxyChain, - portKnockSequence: hosts.portKnockSequence, - domain: hosts.domain, - security: hosts.security, - ignoreCert: hosts.ignoreCert, - guacamoleConfig: hosts.guacamoleConfig, - macAddress: hosts.macAddress, - wolBroadcastAddress: hosts.wolBroadcastAddress, - dockerConfig: hosts.dockerConfig, - proxmoxConfig: hosts.proxmoxConfig, - enableSsh: hosts.enableSsh, - enableRdp: hosts.enableRdp, - enableVnc: hosts.enableVnc, - enableTelnet: hosts.enableTelnet, - sshPort: hosts.sshPort, - rdpPort: hosts.rdpPort, - vncPort: hosts.vncPort, - telnetPort: hosts.telnetPort, - rdpCredentialId: hosts.rdpCredentialId, - rdpUser: hosts.rdpUser, - rdpPassword: hosts.rdpPassword, - rdpDomain: hosts.rdpDomain, - rdpSecurity: hosts.rdpSecurity, - rdpIgnoreCert: hosts.rdpIgnoreCert, - vncAuthType: hosts.vncAuthType, - vncCredentialId: hosts.vncCredentialId, - vncUser: hosts.vncUser, - vncPassword: hosts.vncPassword, - telnetUser: hosts.telnetUser, - telnetPassword: hosts.telnetPassword, - - ownerId: hosts.userId, - isShared: sql`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`, - permissionLevel: hostAccess.permissionLevel, - expiresAt: hostAccess.expiresAt, - }) - .from(hosts) - .leftJoin( - hostAccess, - and( - eq(hostAccess.hostId, hosts.id), - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? inArray(hostAccess.roleId, roleIds) - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - ) - .where( - or( - eq(hosts.userId, userId), - and( - eq(hostAccess.userId, userId), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - roleIds.length > 0 - ? and( - inArray(hostAccess.roleId, roleIds), - or( - isNull(hostAccess.expiresAt), - gte(hostAccess.expiresAt, now), - ), - ) - : sql`false`, - ), + const rawData = + await createCurrentHostResolutionRepository().listHostRowsForAccessList( + userId, + accessEntries, ); const ownHosts = rawData.filter((row) => row.userId === userId); @@ -1384,9 +1295,21 @@ router.get( } } - const sanitizedSharedHosts = sharedHosts; + const ownerUsernames = new Map(); + const userRepository = createCurrentUserRepository(); + for (const sharedHost of sharedHosts) { + const ownerId = sharedHost.userId as string; + if (!ownerUsernames.has(ownerId)) { + try { + const owner = await userRepository.findById(ownerId); + ownerUsernames.set(ownerId, owner?.username ?? ""); + } catch { + ownerUsernames.set(ownerId, ""); + } + } + } - const data = [...decryptedOwnHosts, ...sanitizedSharedHosts]; + const data = [...decryptedOwnHosts, ...sharedHosts]; const result = await Promise.all( data.map(async (row: Record) => { @@ -1395,6 +1318,9 @@ router.get( isShared: !!row.isShared, permissionLevel: row.permissionLevel || undefined, sharedExpiresAt: row.expiresAt || undefined, + ownerUsername: row.isShared + ? ownerUsernames.get(row.userId as string) || undefined + : undefined, }; const resolved = @@ -1403,7 +1329,14 @@ router.get( }), ); - const sanitized = result.map((host) => stripSensitiveFields(host)); + const sanitized = result.map((host) => + host.isShared + ? sanitizeHostForRecipient( + host, + host.permissionLevel as string | undefined, + ) + : stripSensitiveFields(host), + ); res.json(sanitized); } catch (err) { sshLogger.error("Failed to fetch SSH hosts from database", err, { @@ -1458,16 +1391,28 @@ router.get( return res.status(400).json({ error: "Invalid userId or hostId" }); } try { - const data = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))), - "ssh_data", + const hostResolutionRepository = createCurrentHostResolutionRepository(); + const host = await hostResolutionRepository.findHostByIdForUser( + Number(hostId), userId, ); - if (data.length === 0) { + if (host) { + const result = transformHostResponse(host); + const resolved = + (await resolveHostCredentials(result, userId)) || result; + + return res.json(stripSensitiveFields(resolved)); + } + + // Not the owner: shared recipients get a sanitized view of the host. + const accessInfo = await permissionManager.canAccessHost( + userId, + Number(hostId), + "connect", + ); + + if (!accessInfo.hasAccess) { sshLogger.warn("SSH host not found", { operation: "host_fetch_by_id", hostId: parseInt(hostId), @@ -1476,11 +1421,38 @@ router.get( return res.status(404).json({ error: "SSH host not found" }); } - const host = data[0]; - const result = transformHostResponse(host); - const resolved = (await resolveHostCredentials(result, userId)) || result; + const ownerId = await hostResolutionRepository.findHostOwnerId( + Number(hostId), + ); + const sharedHost = ownerId + ? await hostResolutionRepository.findHostById(Number(hostId), ownerId) + : null; - res.json(stripSensitiveFields(resolved)); + if (!sharedHost) { + return res.status(404).json({ error: "SSH host not found" }); + } + + let ownerUsername: string | undefined; + try { + const owner = ownerId + ? await createCurrentUserRepository().findById(ownerId) + : null; + ownerUsername = owner?.username ?? undefined; + } catch { + ownerUsername = undefined; + } + + const sharedResult = { + ...transformHostResponse(sharedHost), + isShared: true, + permissionLevel: accessInfo.permissionLevel, + sharedExpiresAt: accessInfo.expiresAt || undefined, + ownerUsername, + }; + + res.json( + sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel), + ); } catch (err) { sshLogger.error("Failed to fetch SSH host by ID from database", err, { operation: "host_fetch_by_id", @@ -1531,17 +1503,15 @@ router.get( } try { - const data = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, userId, ); - if (data.length === 0) { + if (!host) { return res.status(404).json({ error: "Host not found" }); } - const host = data[0]; const resolved = (await resolveHostCredentials(host, userId)) || host; let value = resolved[field]; @@ -1612,21 +1582,16 @@ router.get( } try { - const hostResults = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const host = + await createCurrentHostResolutionRepository().findHostByIdForUser( + Number(hostId), + userId, + ); - if (hostResults.length === 0) { + if (!host) { return res.status(404).json({ error: "SSH host not found" }); } - const host = hostResults[0]; - const resolvedHost = (await resolveHostCredentials(host, userId)) || host; const exportedConnectionType = @@ -1660,6 +1625,8 @@ router.get( rdpPort: resolvedHost.rdpPort || 3389, vncPort: resolvedHost.vncPort || 5900, telnetPort: resolvedHost.telnetPort || 23, + rdpAuthType: resolvedHost.rdpAuthType || null, + rdpCredentialId: resolvedHost.rdpCredentialId || null, rdpUser: resolvedHost.rdpUser || null, rdpPassword: resolvedHost.rdpPassword || null, rdpDomain: resolvedHost.rdpDomain || null, @@ -1669,6 +1636,8 @@ router.get( vncCredentialId: resolvedHost.vncCredentialId || null, vncUser: resolvedHost.vncUser || null, vncPassword: resolvedHost.vncPassword || null, + telnetAuthType: resolvedHost.telnetAuthType || null, + telnetCredentialId: resolvedHost.telnetCredentialId || null, telnetUser: resolvedHost.telnetUser || null, telnetPassword: resolvedHost.telnetPassword || null, guacamoleConfig: resolvedHost.guacamoleConfig @@ -1774,208 +1743,14 @@ router.get( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const shareExport = - req.query.share === "1" || - req.query.share === "true" || - req.query.safe === "1" || - req.query.safe === "true"; if (!isNonEmptyString(userId)) { return res.status(400).json({ error: "Invalid userId" }); } try { - const allHosts = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); - - if (shareExport) { - const credentials = await SimpleDBOps.select>( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)), - "ssh_credentials", - userId, - ); - const credentialsById = new Map>(); - for (const credential of credentials) { - if (typeof credential.id === "number") { - credentialsById.set(credential.id, credential); - } - } - - const usedAliases = new Set(); - const exportedCredentials = new Map(); - const credentialIdAliases = new Map(); - const directCredentialAliases = new Map(); - - const addCredential = ( - credential: Record, - fallbackName: string, - ) => { - if (typeof credential.id === "number") { - const existing = credentialIdAliases.get(credential.id); - if (existing) return existing; - } - - const alias = uniqueAlias( - String(credential.name || fallbackName), - usedAliases, - ); - exportedCredentials.set( - alias, - safeCredentialExport(credential, alias), - ); - if (typeof credential.id === "number") { - credentialIdAliases.set(credential.id, alias); - } - return alias; - }; - - const addDirectCredential = ( - authType: "password" | "key", - username: unknown, - keyType?: unknown, - ) => { - const usernameText = - typeof username === "string" && username.trim() - ? username.trim() - : "user"; - const key = `${authType}:${usernameText}:${typeof keyType === "string" ? keyType : ""}`; - const existing = directCredentialAliases.get(key); - if (existing) return existing; - - const alias = uniqueAlias(`${usernameText}-${authType}`, usedAliases); - directCredentialAliases.set(key, alias); - exportedCredentials.set( - alias, - safeCredentialExport( - { - name: `${usernameText} ${authType}`, - authType, - username: usernameText, - keyType, - }, - alias, - ), - ); - return alias; - }; - - const exportedHosts = allHosts.map((host) => { - const exportedConnectionType = - (host.connectionType as string) || "ssh"; - const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes( - exportedConnectionType, - ); - - const baseExportData: Record = { - connectionType: exportedConnectionType, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder, - tags: splitTags(host.tags), - notes: host.notes || null, - }; - - if (isRemoteDesktop) { - return { - ...baseExportData, - domain: host.domain || null, - security: host.security || null, - ignoreCert: !!host.ignoreCert, - guacamoleConfig: parseJsonField(host.guacamoleConfig, null), - }; - } - - const exportData: Record = { - ...baseExportData, - authType: host.authType || "none", - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - enableFileManager: host.enableFileManager !== false, - enableDocker: !!host.enableDocker, - enableProxmox: !!host.enableProxmox, - enableTmuxMonitor: !!host.enableTmuxMonitor, - showTerminalInSidebar: !!host.showTerminalInSidebar, - showFileManagerInSidebar: !!host.showFileManagerInSidebar, - showTunnelInSidebar: !!host.showTunnelInSidebar, - showDockerInSidebar: !!host.showDockerInSidebar, - showServerStatsInSidebar: !!host.showServerStatsInSidebar, - defaultPath: host.defaultPath, - tunnelConnections: parseJsonField(host.tunnelConnections, []), - jumpHosts: parseJsonField(host.jumpHosts, null), - quickActions: parseJsonField(host.quickActions, null), - statsConfig: parseJsonField(host.statsConfig, null), - dockerConfig: parseJsonField(host.dockerConfig, null), - proxmoxConfig: parseJsonField(host.proxmoxConfig, null), - forceKeyboardInteractive: host.forceKeyboardInteractive === "true", - useSocks5: !!host.useSocks5, - socks5Host: host.socks5Host || null, - socks5Port: host.socks5Port || null, - socks5Username: host.socks5Username || null, - socks5ProxyChain: parseJsonField(host.socks5ProxyChain, null), - portKnockSequence: parseJsonField(host.portKnockSequence, null), - overrideCredentialUsername: !!host.overrideCredentialUsername, - }; - - if (typeof host.credentialId === "number") { - const credential = credentialsById.get(host.credentialId); - if (credential) { - exportData.authType = "credential"; - exportData.credentialAlias = addCredential( - credential, - String(host.username || "credential"), - ); - return exportData; - } - } - - if (host.authType === "password") { - exportData.authType = "credential"; - exportData.credentialAlias = addDirectCredential( - "password", - host.username, - ); - return exportData; - } - - if (host.authType === "key") { - exportData.authType = "credential"; - exportData.credentialAlias = addDirectCredential( - "key", - host.username, - host.keyType, - ); - return exportData; - } - - if (host.authType === "credential") { - exportData.authType = "none"; - } - - return exportData; - }); - - sshLogger.success("All hosts exported for sharing", { - operation: "hosts_export_share", - count: exportedHosts.length, - credentialCount: exportedCredentials.size, - userId, - }); - - return res.json({ - version: "termix-host-share-v1", - exportedAt: new Date().toISOString(), - credentials: Array.from(exportedCredentials.values()), - hosts: exportedHosts, - }); - } + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); const exportedHosts = []; @@ -2138,12 +1913,13 @@ router.delete( hostId: parseInt(hostId), }); try { - const hostToDelete = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))); + const hostToDelete = + await createCurrentHostResolutionRepository().findHostByIdForUser( + Number(hostId), + userId, + ); - if (hostToDelete.length === 0) { + if (!hostToDelete) { sshLogger.warn("SSH host not found for deletion", { operation: "host_delete", hostId: parseInt(hostId), @@ -2154,48 +1930,35 @@ router.delete( const numericHostId = Number(hostId); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.hostId, numericHostId)); + await createCurrentFileManagerBookmarkRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.hostId, numericHostId)); + await createCurrentTransferRecentRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.hostId, numericHostId)); + await createCurrentCommandHistoryRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(transferRecent) - .where( - or( - eq(transferRecent.sourceHostId, numericHostId), - eq(transferRecent.destHostId, numericHostId), - ), - ); + await createCurrentSshCredentialUsageRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(commandHistory) - .where(eq(commandHistory.hostId, numericHostId)); + await createCurrentRecentActivityRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.hostId, numericHostId)); + await createCurrentRbacAccessRepository().deleteHostAccessForHost( + numericHostId, + ); - await db - .delete(recentActivity) - .where(eq(recentActivity.hostId, numericHostId)); + await createCurrentSessionRecordingRepository().deleteByHostId( + numericHostId, + ); - await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId)); - - await db - .delete(sessionRecordings) - .where(eq(sessionRecordings.hostId, numericHostId)); - - await db - .delete(hosts) - .where(and(eq(hosts.id, numericHostId), eq(hosts.userId, userId))); + await createCurrentHostRepository().deleteForUser(userId, numericHostId); databaseLogger.success("SSH host deleted", { operation: "host_delete_success", @@ -2204,19 +1967,13 @@ router.delete( }); const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req); - const { users: usersTableDel } = await import("../db/schema.js"); - const dhActor = await db - .select({ username: usersTableDel.username }) - .from(usersTableDel) - .where(eq(usersTableDel.id, userId)) - .limit(1); await logAudit({ userId, - username: dhActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "delete_host", resourceType: "host", resourceId: hostId, - resourceName: hostToDelete[0].name ?? hostToDelete[0].ip, + resourceName: hostToDelete.name ?? hostToDelete.ip, ipAddress: dhIp, userAgent: dhUa, success: true, @@ -2278,17 +2035,12 @@ router.get( } try { - const recent = await db - .select() - .from(transferRecent) - .where( - and( - eq(transferRecent.userId, userId), - eq(transferRecent.sourceHostId, sourceHostId), - ), - ) - .orderBy(desc(transferRecent.lastUsed)) - .limit(10); + const recent = + await createCurrentTransferRecentRepository().listBySourceHost( + userId, + sourceHostId, + 10, + ); res.json(recent); } catch (err) { @@ -2315,53 +2067,15 @@ router.post( } try { - const existing = await db - .select() - .from(transferRecent) - .where( - and( - eq(transferRecent.userId, userId), - eq(transferRecent.sourceHostId, sourceHostId), - eq(transferRecent.destHostId, destHostId), - eq(transferRecent.destPath, destPath), - ), - ); + const transferRecentRepository = createCurrentTransferRecentRepository(); + await transferRecentRepository.upsertForDestination(userId, { + sourceHostId, + destHostId, + destPath, + destPathLabel, + }); - if (existing.length > 0) { - await db - .update(transferRecent) - .set({ lastUsed: new Date().toISOString() }) - .where(eq(transferRecent.id, existing[0].id)); - } else { - await db.insert(transferRecent).values({ - userId, - sourceHostId, - destHostId, - destPath, - destPathLabel: destPathLabel || destPath, - lastUsed: new Date().toISOString(), - }); - } - - const allRecent = await db - .select() - .from(transferRecent) - .where( - and( - eq(transferRecent.userId, userId), - eq(transferRecent.sourceHostId, sourceHostId), - ), - ) - .orderBy(desc(transferRecent.lastUsed)); - - if (allRecent.length > 10) { - const toDelete = allRecent.slice(10); - for (const entry of toDelete) { - await db - .delete(transferRecent) - .where(eq(transferRecent.id, entry.id)); - } - } + await transferRecentRepository.pruneSourceHost(userId, sourceHostId, 10); res.json({ message: "Recent destination saved" }); } catch (err) { @@ -2383,13 +2097,14 @@ async function resolveHostCredentials( if (requestingUserId && requestingUserId !== ownerId) { try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - host.id as number, - requestingUserId, - ); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedCred = + await SharedHostSecretsManager.getInstance().getSecretForUser( + host.id as number, + requestingUserId, + "ssh", + ); if (sharedCred) { const resolvedHost: Record = { @@ -2427,25 +2142,16 @@ async function resolveHostCredentials( } } - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", - ownerId, - ); + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + credentialId, + ownerId, + ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { const resolvedHost: Record = { ...host, - password: credential.password, + password: pickResolvedPassword(host.password, credential.password), key: credential.key, keyPassword: credential.keyPassword, keyType: credential.keyType, @@ -2540,31 +2246,20 @@ router.get( } try { - const { opksshTokens } = await import("../db/schema.js"); - const token = await db - .select() - .from(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .limit(1); + const opksshTokenRepository = createCurrentOpksshTokenRepository(); + const tokenData = await opksshTokenRepository.findByUserAndHost( + userId, + hostId, + ); - if (!token || token.length === 0) { + if (!tokenData) { return res.status(404).json({ exists: false }); } - const tokenData = token[0]; const expiresAt = new Date(tokenData.expiresAt); if (expiresAt < new Date()) { - await db - .delete(opksshTokens) - .where( - and( - eq(opksshTokens.userId, userId), - eq(opksshTokens.hostId, hostId), - ), - ); + await opksshTokenRepository.deleteByUserAndHost(userId, hostId); return res.status(404).json({ exists: false }); } @@ -2622,7 +2317,7 @@ router.delete( } try { - const { deleteOPKSSHToken } = await import("../../ssh/opkssh-auth.js"); + const { deleteOPKSSHToken } = await import("../../hosts/opkssh-auth.js"); await deleteOPKSSHToken(userId, hostId); res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/ldap-auth-routes.ts b/src/backend/database/routes/ldap-auth-routes.ts index cefdc6cb..b7cbf7fc 100644 --- a/src/backend/database/routes/ldap-auth-routes.ts +++ b/src/backend/database/routes/ldap-auth-routes.ts @@ -1,14 +1,17 @@ import type { Router } from "express"; import type { LDAPProviderConfig } from "../../../types/index.js"; -import { db } from "../db/index.js"; -import { ssoProviders, users, roles, userRoles } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import { nanoid } from "nanoid"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js"; import ldap from "ldapjs"; +import { + createCurrentRoleRepository, + createCurrentSettingsRepository, + createCurrentSsoProviderRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; const authManager = AuthManager.getInstance(); @@ -120,12 +123,9 @@ export function registerLDAPAuthRoutes(router: Router): void { } try { - const rows = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (rows.length === 0 || rows[0].type !== "ldap" || !rows[0].enabled) { + const provider = + await createCurrentSsoProviderRepository().findById(providerId); + if (!provider || provider.type !== "ldap" || !provider.enabled) { return res.status(404).json({ error: "LDAP provider not found" }); } } catch (err) { @@ -268,22 +268,19 @@ export function registerLDAPAuthRoutes(router: Router): void { const deviceInfo = parseUserAgent(req); const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`; + const userRepository = createCurrentUserRepository(); - let existingUsers = await db - .select() - .from(users) - .where(eq(users.oidcIdentifier, oidcIdentifier)); + let existingUser = + await userRepository.findByOidcIdentifier(oidcIdentifier); let userId: string; - if (existingUsers.length === 0) { + if (!existingUser) { let autoProvision = false; try { - const r = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", - ) - .get() as { value: string } | undefined; - if (r) autoProvision = r.value === "true"; + autoProvision = await createCurrentSettingsRepository().getBoolean( + "oidc_auto_provision", + false, + ); } catch { /* */ } @@ -292,50 +289,31 @@ export function registerLDAPAuthRoutes(router: Router): void { (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"; - const countRow = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const isFirst = (countRow?.count || 0) === 0; + const isFirst = (await userRepository.countAll()) === 0; if (!isFirst && !autoProvision) { return res.status(403).json({ error: "Registration is disabled" }); } userId = nanoid(); - const isFirstFinal = db.$client.transaction(() => { - const c = - ( - db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number } - )?.count || 0; - const first = c === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)", - ) - .run( - userId, - displayName, - "", - first || isAdmin ? 1 : 0, - oidcIdentifier, - providerId, - ); - return first; - })(); + const createdUser = await userRepository.createFirstSsoUser({ + id: userId, + username: displayName, + passwordHash: "", + isAdmin, + isOidc: true, + oidcIdentifier, + ssoProviderId: providerId, + }); + const isFirstFinal = createdUser.isFirstUser; try { const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - if (defaultRole.length > 0) - await db - .insert(userRoles) - .values({ userId, roleId: defaultRole[0].id, grantedBy: userId }); + await createCurrentRoleRepository().assignRoleNameToUser({ + userId, + roleName: defaultRoleName, + grantedBy: userId, + }); } catch { /* */ } @@ -347,7 +325,7 @@ export function registerLDAPAuthRoutes(router: Router): void { : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(userId, sessionDurationMs); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, userId)); + await userRepository.delete(userId); authLogger.error( "Failed to setup LDAP user encryption", encryptionError, @@ -357,47 +335,26 @@ export function registerLDAPAuthRoutes(router: Router): void { .json({ error: "Failed to setup user security" }); } - existingUsers = await db - .select() - .from(users) - .where(eq(users.id, userId)); + existingUser = await userRepository.findById(userId); + if (!existingUser) { + throw new Error("Created LDAP user could not be loaded"); + } } else { - userId = existingUsers[0].id; + userId = existingUser.id; // Sync admin status from group membership - if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) { - await db.update(users).set({ isAdmin }).where(eq(users.id, userId)); - existingUsers[0].isAdmin = isAdmin; + if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) { + existingUser = + (await userRepository.update(userId, { isAdmin })) ?? existingUser; try { const newRoleName = isAdmin ? "admin" : "user"; const oldRoleName = isAdmin ? "user" : "admin"; - const newRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, newRoleName)) - .limit(1); - const oldRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, oldRoleName)) - .limit(1); - if (oldRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, userId), - eq(userRoles.roleId, oldRole[0].id), - ), - ); - } - if (newRole.length > 0) { - await db.insert(userRoles).values({ - userId, - roleId: newRole[0].id, - grantedBy: userId, - }); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId, + addRoleName: newRoleName, + removeRoleName: oldRoleName, + grantedBy: userId, + }); } catch { /* non-fatal */ } @@ -405,17 +362,15 @@ export function registerLDAPAuthRoutes(router: Router): void { // Update display name if not dual-auth const isDualAuth = - existingUsers[0].passwordHash && - existingUsers[0].passwordHash.trim() !== ""; - if (!isDualAuth && existingUsers[0].username !== displayName) { - await db - .update(users) - .set({ username: displayName }) - .where(eq(users.id, userId)); + existingUser.passwordHash && existingUser.passwordHash.trim() !== ""; + if (!isDualAuth && existingUser.username !== displayName) { + existingUser = + (await userRepository.update(userId, { username: displayName })) ?? + existingUser; } } - const userRecord = existingUsers[0]; + const userRecord = existingUser; try { await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch { diff --git a/src/backend/database/routes/network-topology.ts b/src/backend/database/routes/network-topology.ts index d6683124..95167f23 100644 --- a/src/backend/database/routes/network-topology.ts +++ b/src/backend/database/routes/network-topology.ts @@ -1,10 +1,8 @@ import express from "express"; -import { eq } from "drizzle-orm"; -import { getDb } from "../db/index.js"; -import { networkTopology } from "../db/schema.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger } from "../../utils/logger.js"; +import { createCurrentNetworkTopologyRepository } from "../repositories/factory.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -70,14 +68,11 @@ router.get( return res.status(401).json({ error: "User not authenticated" }); } - const db = getDb(); - const result = await db - .select() - .from(networkTopology) - .where(eq(networkTopology.userId, userId)); + const record = + await createCurrentNetworkTopologyRepository().findByUserId(userId); - if (result.length > 0) { - const topologyStr = result[0].topology; + if (record) { + const topologyStr = record.topology; const topology = topologyStr ? JSON.parse(topologyStr) : null; return res.json(topology); } else { @@ -157,26 +152,13 @@ router.post( return res.status(400).json({ error: "Topology data is required" }); } - const db = getDb(); - const topologyStr = typeof topology === "string" ? topology : JSON.stringify(topology); - const existing = await db - .select() - .from(networkTopology) - .where(eq(networkTopology.userId, userId)); - - if (existing.length > 0) { - await db - .update(networkTopology) - .set({ topology: topologyStr }) - .where(eq(networkTopology.userId, userId)); - } else { - await db - .insert(networkTopology) - .values({ userId, topology: topologyStr }); - } + await createCurrentNetworkTopologyRepository().upsertForUser( + userId, + topologyStr, + ); return res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts index b5e04ad3..6ea7c22e 100644 --- a/src/backend/database/routes/open-tabs.ts +++ b/src/backend/database/routes/open-tabs.ts @@ -1,12 +1,13 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { userOpenTabs } from "../db/schema.js"; -import { eq, and, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { sessionManager } from "../../ssh/terminal-session-manager.js"; +import { sessionManager } from "../../hosts/terminal/session-manager.js"; +import { + getCurrentSettingValue, + createCurrentOpenTabRepository, +} from "../repositories/factory.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -27,13 +28,9 @@ const DEFAULT_TAB_TTL_MINUTES = 30; function getTabTtlMs(): number { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", - ) - .get() as { value: string } | undefined; - if (row) { - const minutes = parseInt(row.value, 10); + const value = getCurrentSettingValue("terminal_session_timeout_minutes"); + if (value) { + const minutes = parseInt(value, 10); if (!isNaN(minutes) && minutes > 0) return minutes * 60_000; } } catch { @@ -56,17 +53,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString(); - const tabs = db - .select() - .from(userOpenTabs) - .where( - and( - eq(userOpenTabs.userId, userId), - sql`${userOpenTabs.updatedAt} > ${cutoff}`, - ), - ) - .orderBy(userOpenTabs.tabOrder) - .all(); + const tabs = await createCurrentOpenTabRepository().listRecentForUser( + userId, + cutoff, + ); return res.json( tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })), ); @@ -131,43 +121,14 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { } try { - const now = new Date().toISOString(); - const existing = db - .select() - .from(userOpenTabs) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .all(); - if (existing.length > 0) { - // Preserve existing backendSessionId when not explicitly provided - const sessionId = - backendSessionId !== undefined - ? backendSessionId - : existing[0].backendSessionId; - db.update(userOpenTabs) - .set({ - tabType, - hostId: hostId ?? null, - label, - tabOrder, - backendSessionId: sessionId ?? null, - updatedAt: now, - }) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .run(); - } else { - db.insert(userOpenTabs) - .values({ - id, - userId, - tabType, - hostId: hostId ?? null, - label, - tabOrder, - backendSessionId: backendSessionId ?? null, - updatedAt: now, - }) - .run(); - } + await createCurrentOpenTabRepository().upsertForUser(userId, { + id, + tabType, + hostId, + label, + tabOrder, + backendSessionId, + }); return res.json({ success: true }); } catch (e) { databaseLogger.error("Failed to upsert open tab", e, { @@ -217,24 +178,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { } try { - db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run(); - if (tabs.length > 0) { - const now = new Date().toISOString(); - db.insert(userOpenTabs) - .values( - tabs.map((t) => ({ - id: t.id, - userId, - tabType: t.tabType, - hostId: t.hostId ?? null, - label: t.label, - tabOrder: t.tabOrder, - backendSessionId: t.backendSessionId ?? null, - updatedAt: now, - })), - ) - .run(); - } + await createCurrentOpenTabRepository().replaceForUser(userId, tabs); return res.json({ success: true }); } catch (e) { databaseLogger.error("Failed to sync open tabs", e, { @@ -274,13 +218,13 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => { }>; try { - const result = db - .update(userOpenTabs) - .set({ ...updates, updatedAt: new Date().toISOString() }) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .run(); + const updated = await createCurrentOpenTabRepository().updateForUser( + userId, + id, + updates, + ); - if (result.changes === 0) { + if (!updated) { return res.status(404).json({ error: "Tab not found" }); } return res.json({ success: true }); @@ -316,9 +260,7 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { const id = String(req.params.id); try { - db.delete(userOpenTabs) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .run(); + await createCurrentOpenTabRepository().deleteForUser(userId, id); return res.json({ success: true }); } catch (e) { databaseLogger.error("Failed to delete open tab", e, { diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index fe4991be..c3a4b8fa 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -1,25 +1,28 @@ import express from "express"; import { Client as SSHClient } from "ssh2"; -import { getDb } from "../db/index.js"; -import { hosts, sshCredentials } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import { logger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { + createCurrentCredentialRepository, + createCurrentHostRepository, +} from "../repositories/factory.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { SSHHost } from "../../../types/index.js"; -import { SSHHostKeyVerifier } from "../../ssh/host-key-verifier.js"; +import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js"; const router = express.Router(); const proxmoxLogger = logger; +const runningSyncs = new Set(); + +const MIN_SYNC_INTERVAL_MINUTES = 5; +const DEFAULT_SYNC_INTERVAL_MINUTES = 15; const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -// --------------------------------------------------------------------------- // Helpers -// --------------------------------------------------------------------------- // Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself, // but we validate defensively before using in a shell command. @@ -97,6 +100,11 @@ function parseProxmoxConfig(raw: unknown): { windowsPatterns: string[]; dockerPatterns: string[]; preferredPrefixes: string[]; + defaultCredentialId: number | null; + defaultAuthType: string; + autoSyncEnabled: boolean; + syncIntervalMinutes: number; + markMissingGuests: boolean; } { const split = (s: string) => s @@ -108,10 +116,27 @@ function parseProxmoxConfig(raw: unknown): { windowsPatterns: ["win", "windows"], dockerPatterns: ["docker"], preferredPrefixes: [], + defaultCredentialId: null, + defaultAuthType: "password", + autoSyncEnabled: false, + syncIntervalMinutes: DEFAULT_SYNC_INTERVAL_MINUTES, + markMissingGuests: true, }; } const cfg = raw as Record; + const interval = + typeof cfg.syncIntervalMinutes === "number" + ? cfg.syncIntervalMinutes + : Number.parseInt(String(cfg.syncIntervalMinutes ?? ""), 10); return { + defaultCredentialId: + typeof cfg.defaultCredentialId === "number" + ? cfg.defaultCredentialId + : null, + defaultAuthType: + typeof cfg.defaultAuthType === "string" + ? cfg.defaultAuthType + : "password", windowsPatterns: split( typeof cfg.windowsPatterns === "string" ? cfg.windowsPatterns @@ -123,9 +148,758 @@ function parseProxmoxConfig(raw: unknown): { preferredPrefixes: split( typeof cfg.preferredPrefixes === "string" ? cfg.preferredPrefixes : "", ), + autoSyncEnabled: cfg.autoSyncEnabled === true, + syncIntervalMinutes: + Number.isFinite(interval) && interval >= MIN_SYNC_INTERVAL_MINUTES + ? interval + : DEFAULT_SYNC_INTERVAL_MINUTES, + markMissingGuests: cfg.markMissingGuests !== false, }; } +type ProxmoxGuest = { + name: string; + vmid: number; + type: "qemu" | "lxc"; + node: string; + status: string; + ip: string | null; + connectionType: "ssh" | "rdp"; + enableDocker: boolean; +}; + +type ProxmoxSource = { + source: "proxmox"; + sourceHostId: number; + node: string; + vmid: number; + type: "qemu" | "lxc"; + lastSeenAt?: string; + lastStatus?: string; + missingSince?: string | null; +}; + +type ProxmoxSyncResult = { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; +}; + +function parseJsonObject(value: unknown): Record { + if (!value) return {}; + if (typeof value === "object") return value as Record; + if (typeof value !== "string") return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function getProxmoxSource(host: Record): ProxmoxSource | null { + const config = parseJsonObject(host.proxmoxConfig); + const source = config.source; + if (!source || typeof source !== "object") return null; + const src = source as Record; + if ( + src.source !== "proxmox" || + typeof src.sourceHostId !== "number" || + typeof src.node !== "string" || + typeof src.vmid !== "number" || + (src.type !== "qemu" && src.type !== "lxc") + ) { + return null; + } + return src as ProxmoxSource; +} + +function proxmoxSourceKey(source: ProxmoxSource): string { + return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`; +} + +function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string { + return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`; +} + +function mergeTags( + existing: unknown, + additions: string[], + removals: string[] = [], +): string { + const removeSet = new Set(removals); + const base = + typeof existing === "string" + ? existing + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + : Array.isArray(existing) + ? existing + .map((tag) => (typeof tag === "string" ? tag.trim() : "")) + .filter(Boolean) + : []; + return [...new Set([...base, ...additions])] + .filter((tag) => !removeSet.has(tag)) + .join(","); +} + +function resolveProxmoxImportAuth( + defaultAuthType: string | undefined, + credentialId: number | null | undefined, +): { + authType: string; + credentialId: number | null; + overrideCredentialUsername: number; +} { + if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) { + return credentialId + ? { authType: "credential", credentialId, overrideCredentialUsername: 1 } + : { authType: "none", credentialId: null, overrideCredentialUsername: 0 }; + } + + if (defaultAuthType && !["password", "key"].includes(defaultAuthType)) { + return { + authType: defaultAuthType, + credentialId: null, + overrideCredentialUsername: 0, + }; + } + + return { + authType: "none", + credentialId: null, + overrideCredentialUsername: 0, + }; +} + +async function discoverProxmoxGuestsForHost( + userId: string, + parsedHostId: number, +): Promise<{ + host: SSHHost; + guests: ProxmoxGuest[]; + credentialId: number | null; + defaultCredentialId: number | null; + config: ReturnType; +}> { + if (!DataCrypto.canUserAccessData(userId)) { + const error = new Error("Session expired — please log in again"); + (error as Error & { code?: string }).code = "SESSION_EXPIRED"; + throw error; + } + + const hostRecord = await createCurrentHostRepository().findDecryptedByIdAs( + userId, + parsedHostId, + ); + + if (!hostRecord) { + const error = new Error("Host not found"); + (error as Error & { status?: number }).status = 404; + throw error; + } + + const host = hostRecord as unknown as SSHHost; + const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig); + const config = parseProxmoxConfig(proxmoxCfgRaw); + + if (host.userId !== userId) { + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const pm = PermissionManager.getInstance(); + const access = await pm.canAccessHost(userId, parsedHostId, "connect"); + if (!access.hasAccess) { + const error = new Error("Access denied"); + (error as Error & { status?: number }).status = 403; + throw error; + } + } + + let resolvedCredentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + authType?: string; + } = { + password: host.password, + sshKey: host.key, + keyPassword: host.keyPassword, + authType: host.authType, + }; + + const hostCredentialId = host.credentialId ?? null; + + if (host.credentialId) { + if (userId !== host.userId) { + try { + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedCred = + await SharedHostSecretsManager.getInstance().getSecretForUser( + host.id, + userId, + "ssh", + ); + if (sharedCred) { + resolvedCredentials = { + password: sharedCred.password, + sshKey: sharedCred.key, + keyPassword: sharedCred.keyPassword, + authType: sharedCred.authType, + }; + } + } catch (err) { + proxmoxLogger.error("Failed to resolve shared credential", err, { + operation: "proxmox_discover", + hostId: parsedHostId, + userId, + }); + } + } else { + const cred = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + host.credentialId as number, + ); + if (cred) { + const c = cred; + resolvedCredentials = { + password: c.password as string | undefined, + sshKey: (c.key || c.privateKey) as string | undefined, + keyPassword: c.keyPassword as string | undefined, + authType: c.authType as string | undefined, + }; + } + } + } + + const sshConfig: Record = { + host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, + port: host.port || 22, + username: host.username, + tryKeyboard: false, + readyTimeout: 30000, + hostVerifier: await SSHHostKeyVerifier.createHostVerifier( + parsedHostId, + host.ip, + host.port || 22, + null, + userId, + false, + ), + }; + + const authType = resolvedCredentials.authType; + if (authType === "key" && resolvedCredentials.sshKey) { + sshConfig.privateKey = resolvedCredentials.sshKey; + if (resolvedCredentials.keyPassword) + sshConfig.passphrase = resolvedCredentials.keyPassword; + } else if (authType === "agent") { + const { applyAgentAuth } = + await import("../../hosts/terminal-auth-helpers.js"); + const result = await applyAgentAuth( + sshConfig, + host.terminalConfig as unknown as Record | undefined, + ); + if ("error" in result) { + const error = new Error(result.error); + (error as Error & { status?: number }).status = 400; + throw error; + } + } else if (resolvedCredentials.password) { + sshConfig.password = resolvedCredentials.password; + } + + const client = new SSHClient(); + try { + await new Promise((resolve, reject) => { + client.on("ready", resolve); + client.on("error", reject); + client.connect(sshConfig as import("ssh2").ConnectConfig); + }); + + proxmoxLogger.info("Proxmox discovery SSH connection established", { + operation: "proxmox_discover", + hostId: parsedHostId, + userId, + }); + + const pveshCheck = await execCommand( + client, + "command -v pvesh >/dev/null 2>&1 && echo ok || echo missing", + ); + if (pveshCheck.trim() !== "ok") { + const error = new Error("pvesh not found — is this a Proxmox node?"); + (error as Error & { status?: number }).status = 422; + throw error; + } + + const resourcesJson = await execCommand( + client, + "pvesh get /cluster/resources --output-format json 2>/dev/null", + ); + + let resources: Array>; + try { + resources = JSON.parse(resourcesJson); + } catch { + const error = new Error( + "Failed to parse pvesh output — unexpected response", + ); + (error as Error & { status?: number }).status = 502; + throw error; + } + + type GuestBase = { + name: string; + vmid: number; + type: "qemu" | "lxc"; + node: string; + status: string; + }; + + const guestBases: GuestBase[] = []; + for (const r of resources) { + const type = r.type as string; + if (type !== "qemu" && type !== "lxc") continue; + if (r.template) continue; + const node = r.node as string; + if (!isSafeNodeName(node)) { + proxmoxLogger.warn("Skipping guest with unsafe node name", { + operation: "proxmox_discover", + node, + vmid: r.vmid, + }); + continue; + } + guestBases.push({ + name: (r.name as string) || String(r.vmid), + vmid: Number(r.vmid), + type: type as "qemu" | "lxc", + node, + status: (r.status as string) || "unknown", + }); + } + + async function resolveIp(g: GuestBase): Promise { + if (g.type === "lxc") { + try { + const cfgJson = await execCommand( + client, + `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, + 8000, + ); + return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes); + } catch { + return null; + } + } + if (g.type === "qemu" && g.status === "running") { + try { + const ifJson = await execCommand( + client, + `pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`, + 5000, + ); + const data = JSON.parse(ifJson); + const ifaces: Array> = Array.isArray( + data?.result, + ) + ? data.result + : Array.isArray(data) + ? data + : []; + const allIps: string[] = []; + for (const iface of ifaces) { + if (iface.name === "lo") continue; + const addrs = + (iface["ip-addresses"] as Array>) ?? []; + for (const a of addrs) { + if ( + a["ip-address-type"] === "ipv4" && + !a["ip-address"].startsWith("127.") + ) { + allIps.push(a["ip-address"]); + } + } + } + if (allIps.length) { + for (const prefix of config.preferredPrefixes) { + const match = allIps.find((ip) => ip.startsWith(prefix)); + if (match) return match; + } + return allIps[0]; + } + } catch { + // Guest agent absent or timed out + } + } + return null; + } + + const CONCURRENCY = 6; + const ips: (string | null)[] = new Array(guestBases.length).fill(null); + let cursor = 0; + async function ipWorker() { + while (cursor < guestBases.length) { + const i = cursor++; + ips[i] = await resolveIp(guestBases[i]); + } + } + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () => + ipWorker(), + ), + ); + + const guests: ProxmoxGuest[] = guestBases.map((g, i) => ({ + ...g, + ip: ips[i], + connectionType: matchesAny(g.name, config.windowsPatterns) + ? "rdp" + : "ssh", + enableDocker: matchesAny(g.name, config.dockerPatterns), + })); + + proxmoxLogger.info("Proxmox discovery completed", { + operation: "proxmox_discover", + hostId: parsedHostId, + userId, + guestCount: guests.length, + }); + + return { + host, + guests, + credentialId: hostCredentialId, + defaultCredentialId: config.defaultCredentialId, + config, + }; + } finally { + try { + client.end(); + } catch { + // ignore cleanup errors + } + } +} + +async function syncProxmoxHost( + userId: string, + sourceHostId: number, +): Promise { + const lockKey = `${userId}:${sourceHostId}`; + if (runningSyncs.has(lockKey)) { + return { + created: 0, + updated: 0, + markedMissing: 0, + skipped: 0, + errors: ["Sync already running"], + }; + } + + runningSyncs.add(lockKey); + const result: ProxmoxSyncResult = { + created: 0, + updated: 0, + markedMissing: 0, + skipped: 0, + errors: [], + }; + const startedAt = new Date().toISOString(); + + try { + const discovery = await discoverProxmoxGuestsForHost(userId, sourceHostId); + const sourceHostName = discovery.host.name || "Proxmox"; + const defaultCredentialId = + discovery.config.defaultCredentialId ?? discovery.credentialId ?? null; + const importAuth = resolveProxmoxImportAuth( + discovery.config.defaultAuthType, + defaultCredentialId, + ); + const now = new Date().toISOString(); + + const existingHosts = + (await createCurrentHostRepository().listDecryptedByUserId( + userId, + )) as unknown as Record[]; + const existingBySource = new Map>(); + for (const host of existingHosts) { + const source = getProxmoxSource(host); + if (source?.sourceHostId === sourceHostId) { + existingBySource.set(proxmoxSourceKey(source), host); + } + } + + const seen = new Set(); + for (const guest of discovery.guests) { + const key = guestSourceKey(sourceHostId, guest); + seen.add(key); + const existing = existingBySource.get(key); + const source: ProxmoxSource = { + source: "proxmox", + sourceHostId, + node: guest.node, + vmid: guest.vmid, + type: guest.type, + lastSeenAt: now, + lastStatus: guest.status, + missingSince: null, + }; + + if (!existing && !guest.ip) { + result.skipped++; + result.errors.push( + `${guest.name}: skipped because no IP address was discovered`, + ); + continue; + } + + const baseConfig = existing + ? parseJsonObject(existing.proxmoxConfig) + : {}; + const proxmoxConfig = { + ...baseConfig, + source, + }; + const existingConnectionType = + existing?.connectionType === "ssh" || existing?.connectionType === "rdp" + ? existing.connectionType + : null; + const connectionType = existingConnectionType ?? guest.connectionType; + const port = + typeof existing?.port === "number" + ? existing.port + : connectionType === "rdp" + ? 3389 + : 22; + const username = + typeof existing?.username === "string" && existing.username + ? existing.username + : connectionType === "rdp" + ? null + : "root"; + const update: Record = { + name: guest.name, + ip: guest.ip || existing?.ip, + port, + username, + connectionType, + folder: existing?.folder || sourceHostName, + tags: mergeTags( + existing?.tags, + ["proxmox", guest.type, guest.node], + ["proxmox-missing"], + ), + proxmoxConfig: JSON.stringify(proxmoxConfig), + updatedAt: now, + }; + + if (existing) { + await createCurrentHostRepository().updateEncryptedForUser( + userId, + existing.id as number, + update, + ); + result.updated++; + continue; + } + + Object.assign(update, { + enableTerminal: connectionType !== "rdp", + enableFileManager: connectionType !== "rdp", + enableTunnel: connectionType !== "rdp", + enableDocker: guest.enableDocker, + enableSsh: connectionType === "ssh", + enableRdp: connectionType === "rdp", + }); + + await createCurrentHostRepository().createEncryptedForUser(userId, { + ...update, + userId, + createdAt: now, + pin: false, + authType: connectionType === "rdp" ? "password" : importAuth.authType, + credentialId: connectionType === "ssh" ? importAuth.credentialId : null, + overrideCredentialUsername: importAuth.overrideCredentialUsername, + password: null, + key: null, + keyPassword: null, + keyType: null, + rdpUser: null, + rdpPassword: null, + rdpDomain: null, + rdpSecurity: null, + rdpIgnoreCert: 0, + rdpPort: connectionType === "rdp" ? 3389 : null, + vncUser: null, + vncPassword: null, + vncPort: null, + telnetUser: null, + telnetPassword: null, + telnetPort: null, + defaultPath: "/", + tunnelConnections: "[]", + jumpHosts: null, + quickActions: null, + statsConfig: null, + dockerConfig: null, + terminalConfig: null, + forceKeyboardInteractive: "false", + useSocks5: 0, + socks5Host: null, + socks5Port: null, + socks5Username: null, + socks5Password: null, + socks5ProxyChain: null, + portKnockSequence: null, + showTerminalInSidebar: 0, + showFileManagerInSidebar: 0, + showTunnelInSidebar: 0, + showDockerInSidebar: 0, + showServerStatsInSidebar: 0, + }); + result.created++; + } + + if (discovery.config.markMissingGuests) { + for (const [key, existing] of existingBySource.entries()) { + if (seen.has(key)) continue; + const config = parseJsonObject(existing.proxmoxConfig); + const source = getProxmoxSource(existing); + if (!source) continue; + const missingSince = source.missingSince || now; + await createCurrentHostRepository().updateEncryptedForUser( + userId, + existing.id as number, + { + tags: mergeTags(existing.tags, ["proxmox-missing"]), + proxmoxConfig: JSON.stringify({ + ...config, + source: { + ...source, + missingSince, + }, + }), + updatedAt: now, + }, + ); + result.markedMissing++; + } + } + + await writeSyncStatus(userId, sourceHostId, { + lastSyncAt: startedAt, + lastSyncStatus: "success", + lastSyncError: null, + lastSyncResult: result, + }); + + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + result.errors.push(message); + await writeSyncStatus(userId, sourceHostId, { + lastSyncAt: startedAt, + lastSyncStatus: "error", + lastSyncError: message, + lastSyncResult: result, + }); + throw error; + } finally { + runningSyncs.delete(lockKey); + } +} + +async function writeSyncStatus( + userId: string, + hostId: number, + patch: Record, +): Promise { + const hostRepository = createCurrentHostRepository(); + const row = await hostRepository.findByIdForUser(userId, hostId); + if (!row) return; + const config = parseJsonObject(row.proxmoxConfig); + await hostRepository.updateForUser(userId, hostId, { + proxmoxConfig: JSON.stringify({ ...config, ...patch }), + }); +} + +router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => { + const { hostId } = req.body as { hostId?: unknown }; + const userId = (req as unknown as AuthenticatedRequest).userId; + + const parsedHostId = Number(hostId); + if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) { + return res.status(400).json({ error: "Missing or invalid hostId" }); + } + + try { + const result = await syncProxmoxHost(userId, parsedHostId); + return res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + const status = + (err as Error & { code?: string; status?: number }).code === + "SESSION_EXPIRED" + ? 401 + : (err as Error & { status?: number }).status || 500; + proxmoxLogger.error("Proxmox sync failed", err, { + operation: "proxmox_sync", + hostId: parsedHostId, + userId, + }); + return res.status(status).json({ error: `Sync failed: ${message}` }); + } +}); + +async function runDueProxmoxAutoSyncs(): Promise { + try { + const rows = await createCurrentHostRepository().listProxmoxEnabled(); + + const now = Date.now(); + for (const row of rows) { + const configRaw = parseJsonObject(row.proxmoxConfig); + const config = parseProxmoxConfig(configRaw); + if (!config.autoSyncEnabled) continue; + if (!DataCrypto.canUserAccessData(row.userId)) continue; + + const lastSyncAt = + typeof configRaw.lastSyncAt === "string" + ? Date.parse(configRaw.lastSyncAt) + : 0; + const intervalMs = config.syncIntervalMinutes * 60 * 1000; + if (lastSyncAt && now - lastSyncAt < intervalMs) continue; + + syncProxmoxHost(row.userId, row.id).catch((error) => { + proxmoxLogger.error("Scheduled Proxmox sync failed", error, { + operation: "proxmox_auto_sync", + hostId: row.id, + userId: row.userId, + }); + }); + } + } catch (error) { + proxmoxLogger.error("Failed to scan Proxmox auto sync jobs", error, { + operation: "proxmox_auto_sync_scan", + }); + } +} + +const proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000); +proxmoxAutoSyncTimer.unref?.(); +const proxmoxAutoSyncStartupTimer = setTimeout( + runDueProxmoxAutoSyncs, + 30 * 1000, +); +proxmoxAutoSyncStartupTimer.unref?.(); + /** * @openapi * /proxmox/discover: @@ -215,342 +989,15 @@ router.post( } try { - if (!SimpleDBOps.isUserDataUnlocked(userId)) { - return res.status(401).json({ - error: "Session expired — please log in again", - code: "SESSION_EXPIRED", - }); - } - - // ----------------------------------------------------------------------- - // Load host from DB - // ----------------------------------------------------------------------- - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, parsedHostId)), - "ssh_data", + const discovery = await discoverProxmoxGuestsForHost( userId, + parsedHostId, ); - - if (!hostResults.length) { - return res.status(404).json({ error: "Host not found" }); - } - - const host = hostResults[0] as unknown as SSHHost; - - // Read discovery settings from the host's proxmoxConfig - const proxmoxCfgRaw = host.proxmoxConfig - ? typeof host.proxmoxConfig === "string" - ? JSON.parse(host.proxmoxConfig) - : host.proxmoxConfig - : null; - const { windowsPatterns, dockerPatterns, preferredPrefixes } = - parseProxmoxConfig(proxmoxCfgRaw); - const proxmoxDefaultCredentialId = - proxmoxCfgRaw && typeof proxmoxCfgRaw === "object" - ? (((proxmoxCfgRaw as Record).defaultCredentialId as - | number - | null) ?? null) - : null; - - // ----------------------------------------------------------------------- - // Permission check - // ----------------------------------------------------------------------- - if (host.userId !== userId) { - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const pm = PermissionManager.getInstance(); - const access = await pm.canAccessHost(userId, parsedHostId, "execute"); - if (!access.hasAccess) { - return res.status(403).json({ error: "Access denied" }); - } - } - - // ----------------------------------------------------------------------- - // Credential resolution (mirrors docker.ts pattern) - // ----------------------------------------------------------------------- - let resolvedCredentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - authType?: string; - } = { - password: host.password, - sshKey: host.key, - keyPassword: host.keyPassword, - authType: host.authType, - }; - - const hostCredentialId = host.credentialId ?? null; - - if (host.credentialId) { - if (userId !== host.userId) { - try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCred = - await SharedCredentialManager.getInstance().getSharedCredentialForUser( - host.id, - userId, - ); - if (sharedCred) { - resolvedCredentials = { - password: sharedCred.password, - sshKey: sharedCred.key, - keyPassword: sharedCred.keyPassword, - authType: sharedCred.authType, - }; - } - } catch (err) { - proxmoxLogger.error("Failed to resolve shared credential", err, { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - }); - } - } else { - const creds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); - if (creds.length > 0) { - const c = creds[0]; - resolvedCredentials = { - password: c.password as string | undefined, - sshKey: (c.key || c.privateKey) as string | undefined, - keyPassword: c.keyPassword as string | undefined, - authType: c.authType as string | undefined, - }; - } - } - } - - // ----------------------------------------------------------------------- - // Build SSH config - // ----------------------------------------------------------------------- - const sshConfig: Record = { - host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, - port: host.port || 22, - username: host.username, - tryKeyboard: false, - readyTimeout: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - parsedHostId, - host.ip, - host.port || 22, - null, - userId, - false, - ), - }; - - const authType = resolvedCredentials.authType; - if (authType === "key" && resolvedCredentials.sshKey) { - sshConfig.privateKey = resolvedCredentials.sshKey; - if (resolvedCredentials.keyPassword) - sshConfig.passphrase = resolvedCredentials.keyPassword; - } else if (authType === "agent") { - const { applyAgentAuth } = - await import("../../ssh/terminal-auth-helpers.js"); - const result = await applyAgentAuth( - sshConfig, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - return res.status(400).json({ error: result.error }); - } - } else if (resolvedCredentials.password) { - sshConfig.password = resolvedCredentials.password; - } - - // ----------------------------------------------------------------------- - // Connect → discover → disconnect - // ----------------------------------------------------------------------- - const client = new SSHClient(); - - try { - await new Promise((resolve, reject) => { - client.on("ready", resolve); - client.on("error", reject); - client.connect(sshConfig as import("ssh2").ConnectConfig); - }); - - proxmoxLogger.info("Proxmox discovery SSH connection established", { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - }); - - // Verify pvesh is present — fail fast with a clear error - const pveshCheck = await execCommand( - client, - "command -v pvesh >/dev/null 2>&1 && echo ok || echo missing", - ); - if (pveshCheck.trim() !== "ok") { - return res - .status(422) - .json({ error: "pvesh not found — is this a Proxmox node?" }); - } - - // Fetch all cluster resources in one call - const resourcesJson = await execCommand( - client, - "pvesh get /cluster/resources --output-format json 2>/dev/null", - ); - - let resources: Array>; - try { - resources = JSON.parse(resourcesJson); - } catch { - return res.status(502).json({ - error: "Failed to parse pvesh output — unexpected response", - }); - } - - // Collect basic guest metadata first (no IP yet) - type GuestBase = { - name: string; - vmid: number; - type: "qemu" | "lxc"; - node: string; - status: string; - }; - - const guestBases: GuestBase[] = []; - for (const r of resources) { - const type = r.type as string; - if (type !== "qemu" && type !== "lxc") continue; - if (r.template) continue; - const node = r.node as string; - if (!isSafeNodeName(node)) { - proxmoxLogger.warn("Skipping guest with unsafe node name", { - operation: "proxmox_discover", - node, - vmid: r.vmid, - }); - continue; - } - guestBases.push({ - name: (r.name as string) || String(r.vmid), - vmid: Number(r.vmid), - type: type as "qemu" | "lxc", - node, - status: (r.status as string) || "unknown", - }); - } - - // Resolve IPs for all guests in parallel — keeps total time near - // max(single_resolution) instead of sum(all_resolutions). - async function resolveIp(g: GuestBase): Promise { - if (g.type === "lxc") { - try { - const cfgJson = await execCommand( - client, - `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, - 8000, - ); - return parseLxcIp(JSON.parse(cfgJson), preferredPrefixes); - } catch { - return null; - } - } - if (g.type === "qemu" && g.status === "running") { - try { - const ifJson = await execCommand( - client, - `pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`, - 5000, - ); - const data = JSON.parse(ifJson); - const ifaces: Array> = Array.isArray( - data?.result, - ) - ? data.result - : Array.isArray(data) - ? data - : []; - const allIps: string[] = []; - for (const iface of ifaces) { - if (iface.name === "lo") continue; - const addrs = - (iface["ip-addresses"] as Array>) ?? - []; - for (const a of addrs) { - if ( - a["ip-address-type"] === "ipv4" && - !a["ip-address"].startsWith("127.") - ) { - allIps.push(a["ip-address"]); - } - } - } - if (allIps.length) { - for (const prefix of preferredPrefixes) { - const match = allIps.find((ip) => ip.startsWith(prefix)); - if (match) return match; - } - return allIps[0]; - } - } catch { - // Guest agent absent or timed out - } - } - return null; - } - - // Resolve IPs with bounded concurrency: each lookup opens an SSH exec - // channel, and OpenSSH's default MaxSessions is 10. Capping the number - // of in-flight channels keeps discovery reliable on large clusters. - const CONCURRENCY = 6; - const ips: (string | null)[] = new Array(guestBases.length).fill(null); - let cursor = 0; - async function ipWorker() { - while (cursor < guestBases.length) { - const i = cursor++; - ips[i] = await resolveIp(guestBases[i]); - } - } - await Promise.all( - Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () => - ipWorker(), - ), - ); - const guests = guestBases.map((g, i) => ({ - ...g, - ip: ips[i], - connectionType: matchesAny(g.name, windowsPatterns) ? "rdp" : "ssh", - enableDocker: matchesAny(g.name, dockerPatterns), - })); - - proxmoxLogger.info("Proxmox discovery completed", { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - guestCount: guests.length, - }); - - // Return guests with connection type + docker flag, plus credential info - // so the frontend can pre-populate auth for imported hosts. - return res.json({ - guests, - credentialId: hostCredentialId, - defaultCredentialId: proxmoxDefaultCredentialId, - }); - } finally { - try { - client.end(); - } catch { - // ignore cleanup errors - } - } + return res.json({ + guests: discovery.guests, + credentialId: discovery.credentialId, + defaultCredentialId: discovery.defaultCredentialId, + }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Unknown error"; proxmoxLogger.error("Proxmox discovery failed", err, { @@ -559,15 +1006,17 @@ router.post( userId, }); - if ( - message.includes("pvesh not found") || - message.includes("Authentication failed") || - message.includes("connect ECONNREFUSED") || - message.includes("connect ETIMEDOUT") - ) { - return res.status(422).json({ error: `Discovery failed: ${message}` }); - } - return res.status(500).json({ error: `Discovery failed: ${message}` }); + const status = + (err as Error & { code?: string; status?: number }).code === + "SESSION_EXPIRED" + ? 401 + : (err as Error & { status?: number }).status || + (message.includes("Authentication failed") || + message.includes("connect ECONNREFUSED") || + message.includes("connect ETIMEDOUT") + ? 422 + : 500); + return res.status(status).json({ error: `Discovery failed: ${message}` }); } }, ); diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 5582582f..06fbb12e 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -1,22 +1,25 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - hostAccess, - hosts, - users, - roles, - userRoles, - sharedCredentials, - snippets, - snippetAccess, - sshCredentials, -} from "../db/schema.js"; -import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import type { Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { PermissionManager } from "../../utils/permission-manager.js"; +import { + PermissionManager, + SHARE_PERMISSION_LEVELS, + type SharePermissionLevel, +} from "../../utils/permission-manager.js"; +import { + PERMISSION_CATALOG, + isValidPermission, +} from "../../utils/permission-catalog.js"; +import { + createCurrentCredentialRepository, + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentSnippetRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; const router = express.Router(); @@ -29,12 +32,69 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function isSharePermissionLevel(value: unknown): value is SharePermissionLevel { + return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel); +} + +function expiryFromDuration(durationHours: unknown): string | null { + if (durationHours && typeof durationHours === "number" && durationHours > 0) { + const expiryDate = new Date(); + expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000); + return expiryDate.toISOString(); + } + return null; +} + +// Sharing is controlled by the owner or any recipient holding "manage". +async function canManageHostSharing( + userId: string, + hostId: number, +): Promise<{ allowed: boolean; isOwner: boolean }> { + const access = await permissionManager.canAccessHost( + userId, + hostId, + "manage", + ); + return { allowed: access.hasAccess, isOwner: access.isOwner }; +} + +interface ShareTarget { + type: "user" | "role"; + id: string | number; +} + +function parseShareTargets( + body: Record, +): ShareTarget[] | null { + const rawTargets = body.targets; + if (!Array.isArray(rawTargets) || rawTargets.length === 0) return null; + + const targets: ShareTarget[] = []; + for (const raw of rawTargets) { + if (!raw || typeof raw !== "object") return null; + const { type, id } = raw as { type?: unknown; id?: unknown }; + if (type === "user" && isNonEmptyString(id)) { + targets.push({ type: "user", id }); + } else if ( + type === "role" && + typeof id === "number" && + Number.isInteger(id) + ) { + targets.push({ type: "role", id }); + } else { + return null; + } + } + + return targets; +} + /** * @openapi * /rbac/host/{id}/share: * post: * summary: Share a host - * description: Shares a host with a user or a role. + * description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically. * tags: * - RBAC * parameters: @@ -49,26 +109,32 @@ function isNonEmptyString(value: unknown): value is string { * application/json: * schema: * type: object + * required: [targets] * properties: - * targetType: - * type: string - * enum: [user, role] - * targetUserId: - * type: string - * targetRoleId: - * type: integer - * durationHours: - * type: number + * targets: + * type: array + * items: + * type: object + * properties: + * type: + * type: string + * enum: [user, role] + * id: + * oneOf: + * - type: string + * - type: integer * permissionLevel: * type: string - * enum: [view] + * enum: [connect, view, edit, manage] + * durationHours: + * type: number * responses: * 200: * description: Host shared successfully. * 400: * description: Invalid request body. * 403: - * description: Not host owner. + * description: Caller may not share this host. * 404: * description: Target user or role not found. * 500: @@ -87,38 +153,25 @@ router.post( } try { - const { - targetType = "user", - targetUserId, - targetRoleId, - durationHours, - permissionLevel = "view", - } = req.body; - - if (!["user", "role"].includes(targetType)) { - return res - .status(400) - .json({ error: "Invalid target type. Must be 'user' or 'role'" }); + const targets = parseShareTargets(req.body ?? {}); + if (!targets) { + return res.status(400).json({ + error: + "targets must be a non-empty array of { type: 'user'|'role', id } entries", + }); } - if (targetType === "user" && !isNonEmptyString(targetUserId)) { - return res - .status(400) - .json({ error: "Target user ID is required when sharing with user" }); - } - if (targetType === "role" && !targetRoleId) { - return res - .status(400) - .json({ error: "Target role ID is required when sharing with role" }); + const { durationHours, permissionLevel = "connect" } = req.body; + + if (!isSharePermissionLevel(permissionLevel)) { + return res.status(400).json({ + error: "Invalid permission level", + validLevels: SHARE_PERMISSION_LEVELS, + }); } - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); - - if (host.length === 0) { + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { databaseLogger.warn("Permission denied", { operation: "rbac_permission_denied", userId, @@ -126,174 +179,126 @@ router.post( resourceId: hostId, action: "share", }); - return res.status(403).json({ error: "Not host owner" }); + return res.status(403).json({ error: "You may not share this host" }); } - if ( - !host[0].credentialId && - !host[0].rdpCredentialId && - !host[0].vncCredentialId && - host[0].authType !== "opkssh" - ) { - return res.status(400).json({ - error: - "Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.", - code: "CREDENTIAL_REQUIRED_FOR_SHARING", - }); + const host = + await createCurrentHostResolutionRepository().findHostUpdateState( + hostId, + ); + if (!host) { + return res.status(404).json({ error: "Host not found" }); } + const ownerId = host.userId; - if (targetType === "user") { - const targetUser = await db - .select({ id: users.id, username: users.username }) - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); + const userRepository = createCurrentUserRepository(); + const roleRepository = createCurrentRoleRepository(); - if (targetUser.length === 0) { - return res.status(404).json({ error: "Target user not found" }); - } - } else { - const targetRole = await db - .select({ id: roles.id, name: roles.name }) - .from(roles) - .where(eq(roles.id, targetRoleId)) - .limit(1); - - if (targetRole.length === 0) { - return res.status(404).json({ error: "Target role not found" }); - } - } - - let expiresAt: string | null = null; - if ( - durationHours && - typeof durationHours === "number" && - durationHours > 0 - ) { - const expiryDate = new Date(); - expiryDate.setHours(expiryDate.getHours() + durationHours); - expiresAt = expiryDate.toISOString(); - } - - const validLevels = ["view"]; - if (!validLevels.includes(permissionLevel)) { - return res.status(400).json({ - error: "Invalid permission level. Only 'view' is supported.", - validLevels, - }); - } - - const whereConditions = [eq(hostAccess.hostId, hostId)]; - if (targetType === "user") { - whereConditions.push(eq(hostAccess.userId, targetUserId)); - } else { - whereConditions.push(eq(hostAccess.roleId, targetRoleId)); - } - - const existing = await db - .select() - .from(hostAccess) - .where(and(...whereConditions)) - .limit(1); - - if (existing.length > 0) { - await db - .update(hostAccess) - .set({ - permissionLevel, - expiresAt, - }) - .where(eq(hostAccess.id, existing[0].id)); - - await db - .delete(sharedCredentials) - .where(eq(sharedCredentials.hostAccessId, existing[0].id)); - - const activeCredentialId = - host[0].credentialId ?? - host[0].rdpCredentialId ?? - host[0].vncCredentialId; - if (activeCredentialId) { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - if (targetType === "user") { - await sharedCredManager.createSharedCredentialForUser( - existing[0].id, - activeCredentialId, - targetUserId!, - userId, - ); - } else { - await sharedCredManager.createSharedCredentialsForRole( - existing[0].id, - activeCredentialId, - targetRoleId!, - userId, - ); + for (const target of targets) { + if (target.type === "user") { + if (target.id === ownerId) { + return res + .status(400) + .json({ error: "Cannot share a host with its owner" }); + } + const targetUser = await userRepository.findById(target.id as string); + if (!targetUser) { + return res.status(404).json({ + error: "Target user not found", + targetId: target.id, + }); + } + } else { + const targetRole = await roleRepository.findRoleById( + target.id as number, + ); + if (!targetRole) { + return res.status(404).json({ + error: "Target role not found", + targetId: target.id, + }); } } - databaseLogger.info("Permission granted", { - operation: "rbac_permission_grant", - adminId: userId, + } + + const expiresAt = expiryFromDuration(durationHours); + + const rbacAccessRepository = createCurrentRbacAccessRepository(); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const secretsManager = SharedHostSecretsManager.getInstance(); + + const results: Array<{ + type: "user" | "role"; + id: string | number; + accessId: number; + created: boolean; + }> = []; + + for (const target of targets) { + const accessGrant = await rbacAccessRepository.upsertHostAccess({ hostId, - resource: "host", - action: "view", - }); - - return res.json({ - success: true, - message: "Host access updated", + grantedBy: userId, + permissionLevel, expiresAt, + ...(target.type === "user" + ? { targetType: "user" as const, targetUserId: target.id as string } + : { + targetType: "role" as const, + targetRoleId: target.id as number, + }), + }); + + try { + if (target.type === "user") { + await secretsManager.snapshotForUser( + accessGrant.id, + hostId, + target.id as string, + ownerId, + ); + } else { + await secretsManager.snapshotForRole( + accessGrant.id, + hostId, + target.id as number, + ownerId, + ); + } + } catch (snapshotError) { + databaseLogger.warn("Share created but secret snapshot failed", { + operation: "rbac_host_share_snapshot_failed", + hostId, + accessId: accessGrant.id, + error: + snapshotError instanceof Error + ? snapshotError.message + : "Unknown error", + }); + } + + results.push({ + type: target.type, + id: target.id, + accessId: accessGrant.id, + created: accessGrant.created, }); } - const result = await db.insert(hostAccess).values({ - hostId, - userId: targetType === "user" ? targetUserId : null, - roleId: targetType === "role" ? targetRoleId : null, - grantedBy: userId, - permissionLevel, - expiresAt, - }); - - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - - const activeCredentialId = - host[0].credentialId ?? - host[0].rdpCredentialId ?? - host[0].vncCredentialId; - if (activeCredentialId) { - if (targetType === "user") { - await sharedCredManager.createSharedCredentialForUser( - result.lastInsertRowid as number, - activeCredentialId, - targetUserId!, - userId, - ); - } else { - await sharedCredManager.createSharedCredentialsForRole( - result.lastInsertRowid as number, - activeCredentialId, - targetRoleId!, - userId, - ); - } - } databaseLogger.success("Host shared successfully", { operation: "rbac_host_share_success", userId, hostId, - targetUserId: targetType === "user" ? targetUserId : undefined, + targets: results.length, permissionLevel, }); res.json({ success: true, - message: `Host shared successfully with ${targetType}`, + message: "Host shared successfully", + permissionLevel, expiresAt, + results, }); } catch (error) { databaseLogger.error("Failed to share host", error, { @@ -306,6 +311,143 @@ router.post( }, ); +/** + * @openapi + * /rbac/host/{id}/access/{accessId}: + * patch: + * summary: Update a host access grant + * description: Changes the permission level and/or expiry of an existing host access grant. Allowed for the host owner or recipients holding the manage level. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: path + * name: accessId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * permissionLevel: + * type: string + * enum: [connect, view, edit, manage] + * durationHours: + * type: number + * nullable: true + * description: Hours from now until the grant expires; null clears the expiry. + * responses: + * 200: + * description: Grant updated successfully. + * 400: + * description: Invalid request. + * 403: + * description: Caller may not manage sharing on this host. + * 404: + * description: Grant not found. + * 500: + * description: Failed to update grant. + */ +router.patch( + "/host/:id/access/:accessId", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const accessIdParam = Array.isArray(req.params.accessId) + ? req.params.accessId[0] + : req.params.accessId; + const hostId = parseInt(id, 10); + const accessId = parseInt(accessIdParam, 10); + const userId = req.userId!; + + if (isNaN(hostId) || isNaN(accessId)) { + return res.status(400).json({ error: "Invalid ID" }); + } + + try { + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { + return res + .status(403) + .json({ error: "You may not manage sharing on this host" }); + } + + const { permissionLevel, durationHours } = req.body ?? {}; + + if (permissionLevel === undefined && durationHours === undefined) { + return res.status(400).json({ + error: "At least one of permissionLevel or durationHours is required", + }); + } + + if ( + permissionLevel !== undefined && + !isSharePermissionLevel(permissionLevel) + ) { + return res.status(400).json({ + error: "Invalid permission level", + validLevels: SHARE_PERMISSION_LEVELS, + }); + } + + const rbacAccessRepository = createCurrentRbacAccessRepository(); + const grant = await rbacAccessRepository.findHostAccessById( + accessId, + hostId, + ); + if (!grant) { + return res.status(404).json({ error: "Access grant not found" }); + } + + const update: { permissionLevel?: string; expiresAt?: string | null } = + {}; + if (permissionLevel !== undefined) { + update.permissionLevel = permissionLevel; + } + if (durationHours !== undefined) { + update.expiresAt = + durationHours === null ? null : expiryFromDuration(durationHours); + } + + await rbacAccessRepository.updateHostAccessGrant( + accessId, + hostId, + update, + ); + + databaseLogger.info("Host access grant updated", { + operation: "rbac_host_access_update", + userId, + hostId, + accessId, + permissionLevel, + }); + + res.json({ + success: true, + message: "Access updated", + expiresAt: update.expiresAt ?? grant.expiresAt, + }); + } catch (error) { + databaseLogger.error("Failed to update host access", error, { + operation: "update_host_access", + hostId, + accessId, + userId, + }); + res.status(500).json({ error: "Failed to update access" }); + } + }, +); + /** * @openapi * /rbac/host/{id}/access/{accessId}: @@ -331,7 +473,7 @@ router.post( * 400: * description: Invalid ID. * 403: - * description: Not host owner. + * description: Caller may not manage sharing on this host. * 500: * description: Failed to revoke access. */ @@ -352,17 +494,17 @@ router.delete( } try { - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); - - if (host.length === 0) { - return res.status(403).json({ error: "Not host owner" }); + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { + return res + .status(403) + .json({ error: "You may not manage sharing on this host" }); } - await db.delete(hostAccess).where(eq(hostAccess.id, accessId)); + await createCurrentRbacAccessRepository().revokeHostAccess( + accessId, + hostId, + ); databaseLogger.info("Permission revoked", { operation: "rbac_permission_revoke", adminId: userId, @@ -399,11 +541,11 @@ router.delete( * type: integer * responses: * 200: - * description: The access list for the host. + * description: The access list for the host, including each grant's permission level. * 400: * description: Invalid host ID. * 403: - * description: Not host owner. + * description: Caller may not manage sharing on this host. * 500: * description: Failed to get access list. */ @@ -420,52 +562,17 @@ router.get( } try { - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); - - if (host.length === 0) { - return res.status(403).json({ error: "Not host owner" }); + const sharing = await canManageHostSharing(userId, hostId); + if (!sharing.allowed) { + return res + .status(403) + .json({ error: "You may not manage sharing on this host" }); } - const rawAccessList = await db - .select({ - id: hostAccess.id, - userId: hostAccess.userId, - roleId: hostAccess.roleId, - username: users.username, - roleName: roles.name, - roleDisplayName: roles.displayName, - grantedBy: hostAccess.grantedBy, - grantedByUsername: sql`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, - permissionLevel: hostAccess.permissionLevel, - expiresAt: hostAccess.expiresAt, - createdAt: hostAccess.createdAt, - }) - .from(hostAccess) - .leftJoin(users, eq(hostAccess.userId, users.id)) - .leftJoin(roles, eq(hostAccess.roleId, roles.id)) - .where(eq(hostAccess.hostId, hostId)) - .orderBy(desc(hostAccess.createdAt)); + const accessList = + await createCurrentRbacAccessRepository().listHostAccess(hostId); - const accessList = rawAccessList.map((access) => ({ - id: access.id, - targetType: access.userId ? "user" : "role", - userId: access.userId, - roleId: access.roleId, - username: access.username, - roleName: access.roleName, - roleDisplayName: access.roleDisplayName, - grantedBy: access.grantedBy, - grantedByUsername: access.grantedByUsername, - permissionLevel: access.permissionLevel, - expiresAt: access.expiresAt, - createdAt: access.createdAt, - })); - - res.json({ accessList }); + res.json({ accessList, isOwner: sharing.isOwner }); } catch (error) { databaseLogger.error("Failed to get host access list", error, { operation: "get_host_access_list", @@ -498,46 +605,13 @@ router.get( const userId = req.userId!; try { - const now = new Date().toISOString(); - - const userRoleIds = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); - - const sharedHosts = await db - .select({ - id: hosts.id, - name: hosts.name, - ip: hosts.ip, - port: hosts.port, - username: hosts.username, - folder: hosts.folder, - tags: hosts.tags, - permissionLevel: hostAccess.permissionLevel, - expiresAt: hostAccess.expiresAt, - grantedBy: hostAccess.grantedBy, - ownerUsername: users.username, - }) - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .innerJoin(users, eq(hosts.userId, users.id)) - .where( - and( - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? sql`${hostAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - ) - .orderBy(desc(hostAccess.createdAt)); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const sharedHosts = + await createCurrentRbacAccessRepository().listSharedHosts( + userId, + roleIds, + ); res.json({ sharedHosts }); } catch (error) { @@ -569,18 +643,38 @@ router.get( authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { try { - const rolesList = await db - .select({ - id: roles.id, - name: roles.name, - displayName: roles.displayName, - description: roles.description, - isSystem: roles.isSystem, - createdAt: roles.createdAt, - updatedAt: roles.updatedAt, - }) - .from(roles) - .orderBy(roles.isSystem, roles.name); + const rolesList = (await createCurrentRoleRepository().listRoles()).map( + ({ + id, + name, + displayName, + description, + isSystem, + permissions, + createdAt, + updatedAt, + }) => { + let parsedPermissions: string[] = []; + try { + parsedPermissions = permissions + ? (JSON.parse(permissions) as string[]) + : []; + } catch { + parsedPermissions = []; + } + + return { + id, + name, + displayName, + description, + isSystem, + permissions: parsedPermissions, + createdAt, + updatedAt, + }; + }, + ); res.json({ roles: rolesList }); } catch (error) { @@ -644,19 +738,15 @@ router.post( } try { - const existing = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, name)) - .limit(1); + const existing = await createCurrentRoleRepository().findRoleByName(name); - if (existing.length > 0) { + if (existing) { return res.status(409).json({ error: "A role with this name already exists", }); } - const result = await db.insert(roles).values({ + const newRoleId = await createCurrentRoleRepository().createRole({ name, displayName, description: description || null, @@ -664,8 +754,6 @@ router.post( permissions: null, }); - const newRoleId = result.lastInsertRowid; - res.status(201).json({ success: true, roleId: newRoleId, @@ -706,6 +794,11 @@ router.post( * type: string * description: * type: string + * permissions: + * type: array + * items: + * type: string + * description: Permission strings validated against the permissions catalog (wildcards like hosts.* and * allowed). * responses: * 200: * description: Role updated successfully. @@ -723,36 +816,56 @@ router.put( async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const roleId = parseInt(id, 10); - const { displayName, description } = req.body; + const { displayName, description, permissions } = req.body; if (isNaN(roleId)) { return res.status(400).json({ error: "Invalid role ID" }); } - if (!displayName && description === undefined) { + if ( + !displayName && + description === undefined && + permissions === undefined + ) { return res.status(400).json({ - error: "At least one field (displayName or description) is required", + error: + "At least one field (displayName, description or permissions) is required", }); } - try { - const existingRole = await db - .select({ - id: roles.id, - name: roles.name, - isSystem: roles.isSystem, - }) - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + if (permissions !== undefined) { + if ( + !Array.isArray(permissions) || + permissions.some((perm) => typeof perm !== "string") + ) { + return res + .status(400) + .json({ error: "permissions must be an array of strings" }); + } - if (existingRole.length === 0) { + const invalid = (permissions as string[]).filter( + (perm) => !isValidPermission(perm), + ); + if (invalid.length > 0) { + return res.status(400).json({ + error: "Unknown permissions", + invalid, + }); + } + } + + try { + const roleRepository = createCurrentRoleRepository(); + const existingRole = await roleRepository.findRoleById(roleId); + + if (!existingRole) { return res.status(404).json({ error: "Role not found" }); } const updates: { displayName?: string; description?: string | null; + permissions?: string | null; updatedAt: string; } = { updatedAt: new Date().toISOString(), @@ -766,7 +879,18 @@ router.put( updates.description = description || null; } - await db.update(roles).set(updates).where(eq(roles.id, roleId)); + if (permissions !== undefined) { + updates.permissions = JSON.stringify(permissions); + } + + await roleRepository.updateRole(roleId, updates); + + if (permissions !== undefined) { + const memberIds = await roleRepository.listRoleUserIds(roleId); + for (const memberId of memberIds) { + permissionManager.invalidateUserPermissionCache(memberId); + } + } res.json({ success: true, @@ -782,6 +906,26 @@ router.put( }, ); +/** + * @openapi + * /rbac/permissions/catalog: + * get: + * summary: Get the role permissions catalog + * description: Returns the grouped catalog of role permission strings used by the role permissions editor. + * tags: + * - RBAC + * responses: + * 200: + * description: The permissions catalog. + */ +router.get( + "/permissions/catalog", + authenticateJWT, + async (_req: AuthenticatedRequest, res: Response) => { + res.json({ catalog: PERMISSION_CATALOG }); + }, +); + /** * @openapi * /rbac/roles/{id}: @@ -821,39 +965,25 @@ router.delete( } try { - const role = await db - .select({ - id: roles.id, - name: roles.name, - isSystem: roles.isSystem, - }) - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const role = await createCurrentRoleRepository().findRoleById(roleId); - if (role.length === 0) { + if (!role) { return res.status(404).json({ error: "Role not found" }); } - if (role[0].isSystem) { + if (role.isSystem) { return res.status(403).json({ error: "Cannot delete system roles", }); } - const deletedUserRoles = await db - .delete(userRoles) - .where(eq(userRoles.roleId, roleId)) - .returning({ userId: userRoles.userId }); + const { deletedUserIds } = + await createCurrentRoleRepository().deleteRole(roleId); - for (const { userId } of deletedUserRoles) { + for (const userId of deletedUserIds) { permissionManager.invalidateUserPermissionCache(userId); } - await db.delete(hostAccess).where(eq(hostAccess.roleId, roleId)); - - await db.delete(roles).where(eq(roles.id, roleId)); - res.json({ success: true, message: "Role deleted successfully", @@ -922,83 +1052,58 @@ router.post( return res.status(400).json({ error: "Role ID is required" }); } - const targetUser = await db - .select() - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); + const targetUser = + await createCurrentUserRepository().findById(targetUserId); - if (targetUser.length === 0) { + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - const role = await db - .select() - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const role = await createCurrentRoleRepository().findRoleById(roleId); - if (role.length === 0) { + if (!role) { return res.status(404).json({ error: "Role not found" }); } - if (role[0].isSystem) { + if (role.isSystem) { return res.status(403).json({ error: "System roles (admin, user) are automatically assigned and cannot be manually assigned", }); } - const existing = await db - .select() - .from(userRoles) - .where( - and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), - ) - .limit(1); + const existing = await createCurrentRoleRepository().findUserRole( + targetUserId, + roleId, + ); - if (existing.length > 0) { + if (existing) { return res.status(409).json({ error: "Role already assigned" }); } - await db.insert(userRoles).values({ + await createCurrentRoleRepository().assignRoleToUser({ userId: targetUserId, roleId, grantedBy: currentUserId, }); - const hostsSharedWithRole = await db - .select() - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .where(eq(hostAccess.roleId, roleId)); - - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - - for (const { host_access, ssh_data } of hostsSharedWithRole) { - if (ssh_data.credentialId) { - try { - await sharedCredManager.createSharedCredentialForUser( - host_access.id, - ssh_data.credentialId, - targetUserId, - ssh_data.userId, - ); - } catch (error) { - databaseLogger.error( - "Failed to create shared credential for new role member", - error, - { - operation: "assign_role_create_credentials", - targetUserId, - roleId, - hostId: ssh_data.id, - }, - ); - } - } + try { + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().snapshotForRoleMember( + roleId, + targetUserId, + ); + } catch (error) { + databaseLogger.error( + "Failed to snapshot shared host secrets for new role member", + error, + { + operation: "assign_role_snapshot_secrets", + targetUserId, + roleId, + }, + ); } permissionManager.invalidateUserPermissionCache(targetUserId); @@ -1007,7 +1112,7 @@ router.post( adminId: currentUserId, targetUserId, roleId, - roleName: role[0].name, + roleName: role.name, }); res.json({ @@ -1073,32 +1178,45 @@ router.delete( } try { - const role = await db - .select({ - id: roles.id, - name: roles.name, - isSystem: roles.isSystem, - }) - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const role = await createCurrentRoleRepository().findRoleById(roleId); - if (role.length === 0) { + if (!role) { return res.status(404).json({ error: "Role not found" }); } - if (role[0].isSystem) { + if (role.isSystem) { return res.status(403).json({ error: "System roles (admin, user) are automatically assigned and cannot be removed", }); } - await db - .delete(userRoles) - .where( - and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), + await createCurrentRoleRepository().removeRoleFromUser( + targetUserId, + roleId, + ); + + try { + const { createCurrentSharedHostSecretsRepository } = + await import("../repositories/factory.js"); + await createCurrentSharedHostSecretsRepository().deleteForRoleMember( + roleId, + targetUserId, ); + } catch (cleanupError) { + databaseLogger.warn( + "Failed to clean shared host secrets after role removal", + { + operation: "remove_role_secret_cleanup", + targetUserId, + roleId, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }, + ); + } permissionManager.invalidateUserPermissionCache(targetUserId); databaseLogger.info("Role removed from user", { @@ -1162,19 +1280,8 @@ router.get( } try { - const userRolesList = await db - .select({ - id: userRoles.id, - roleId: roles.id, - roleName: roles.name, - roleDisplayName: roles.displayName, - description: roles.description, - isSystem: roles.isSystem, - grantedAt: userRoles.grantedAt, - }) - .from(userRoles) - .innerJoin(roles, eq(userRoles.roleId, roles.id)) - .where(eq(userRoles.userId, targetUserId)); + const userRolesList = + await createCurrentRoleRepository().listUserRoles(targetUserId); res.json({ roles: userRolesList }); } catch (error) { @@ -1187,9 +1294,7 @@ router.get( }, ); -// ============================================================================ // SNIPPET SHARING -// ============================================================================ /** * @openapi @@ -1237,32 +1342,25 @@ router.post( .json({ error: "Target role ID is required when sharing with role" }); } - const snippet = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const snippet = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (snippet.length === 0) { + if (!snippet) { return res.status(403).json({ error: "Not snippet owner" }); } if (targetType === "user") { - const targetUser = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); - if (targetUser.length === 0) { + const targetUser = + await createCurrentUserRepository().findById(targetUserId); + if (!targetUser) { return res.status(404).json({ error: "Target user not found" }); } } else { - const targetRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.id, targetRoleId)) - .limit(1); - if (targetRole.length === 0) { + const targetRole = + await createCurrentRoleRepository().findRoleById(targetRoleId); + if (!targetRole) { return res.status(404).json({ error: "Target role not found" }); } } @@ -1278,25 +1376,17 @@ router.post( expiresAt = expiryDate.toISOString(); } - const whereConditions = [eq(snippetAccess.snippetId, snippetId)]; - if (targetType === "user") { - whereConditions.push(eq(snippetAccess.userId, targetUserId)); - } else { - whereConditions.push(eq(snippetAccess.roleId, targetRoleId)); - } - - const existing = await db - .select() - .from(snippetAccess) - .where(and(...whereConditions)) - .limit(1); - - if (existing.length > 0) { - await db - .update(snippetAccess) - .set({ expiresAt }) - .where(eq(snippetAccess.id, existing[0].id)); + const accessGrant = + await createCurrentRbacAccessRepository().upsertSnippetAccess({ + snippetId, + grantedBy: userId, + expiresAt, + ...(targetType === "user" + ? { targetType: "user" as const, targetUserId: targetUserId! } + : { targetType: "role" as const, targetRoleId: targetRoleId! }), + }); + if (!accessGrant.created) { return res.json({ success: true, message: "Snippet access updated", @@ -1304,15 +1394,6 @@ router.post( }); } - await db.insert(snippetAccess).values({ - snippetId, - userId: targetType === "user" ? targetUserId : null, - roleId: targetType === "role" ? targetRoleId : null, - grantedBy: userId, - permissionLevel: "view", - expiresAt, - }); - databaseLogger.success("Snippet shared successfully", { operation: "rbac_snippet_share", userId, @@ -1359,17 +1440,19 @@ router.delete( } try { - const snippet = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const snippet = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (snippet.length === 0) { + if (!snippet) { return res.status(403).json({ error: "Not snippet owner" }); } - await db.delete(snippetAccess).where(eq(snippetAccess.id, accessId)); + await createCurrentRbacAccessRepository().revokeSnippetAccess( + accessId, + snippetId, + ); res.json({ success: true, message: "Snippet access revoked" }); } catch (error) { @@ -1404,50 +1487,17 @@ router.get( } try { - const snippet = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const snippet = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (snippet.length === 0) { + if (!snippet) { return res.status(403).json({ error: "Not snippet owner" }); } - const rawAccessList = await db - .select({ - id: snippetAccess.id, - userId: snippetAccess.userId, - roleId: snippetAccess.roleId, - username: users.username, - roleName: roles.name, - roleDisplayName: roles.displayName, - grantedBy: snippetAccess.grantedBy, - grantedByUsername: sql`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - createdAt: snippetAccess.createdAt, - }) - .from(snippetAccess) - .leftJoin(users, eq(snippetAccess.userId, users.id)) - .leftJoin(roles, eq(snippetAccess.roleId, roles.id)) - .where(eq(snippetAccess.snippetId, snippetId)) - .orderBy(desc(snippetAccess.createdAt)); - - const accessList = rawAccessList.map((access) => ({ - id: access.id, - targetType: access.userId ? "user" : "role", - userId: access.userId, - roleId: access.roleId, - username: access.username, - roleName: access.roleName, - roleDisplayName: access.roleDisplayName, - grantedBy: access.grantedBy, - grantedByUsername: access.grantedByUsername, - permissionLevel: access.permissionLevel, - expiresAt: access.expiresAt, - createdAt: access.createdAt, - })); + const accessList = + await createCurrentRbacAccessRepository().listSnippetAccess(snippetId); res.json({ accessList }); } catch (error) { @@ -1476,72 +1526,15 @@ router.get( const userId = req.userId!; try { - const now = new Date().toISOString(); - - const directShared = await db - .select({ - id: snippets.id, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - ownerUsername: users.username, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .innerJoin(users, eq(snippets.userId, users.id)) - .where( - and( - eq(snippetAccess.userId, userId), - or( - isNull(snippetAccess.expiresAt), - gte(snippetAccess.expiresAt, now), - ), - ), + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const sharedSnippets = + await createCurrentRbacAccessRepository().listSharedSnippets( + userId, + roleIds, ); - const userRoleRows = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleRows.map((r) => r.roleId); - - let roleShared: typeof directShared = []; - if (roleIds.length > 0) { - const directIds = directShared.map((s) => s.id); - const roleResults = await db - .select({ - id: snippets.id, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - ownerUsername: users.username, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .innerJoin(users, eq(snippets.userId, users.id)) - .where( - and( - or( - isNull(snippetAccess.expiresAt), - gte(snippetAccess.expiresAt, now), - ), - sql`${snippetAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ), - ); - - roleShared = roleResults.filter((s) => !directIds.includes(s.id)); - } - - res.json({ sharedSnippets: [...directShared, ...roleShared] }); + res.json({ sharedSnippets }); } catch (error) { databaseLogger.error("Failed to get shared snippets", error, { operation: "get_shared_snippets", @@ -1564,39 +1557,31 @@ router.put( return res.status(400).json({ error: "Invalid host ID" }); } - const access = await db - .select() - .from(hostAccess) - .where( - and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), - ) - .limit(1); + const access = + await createCurrentRbacAccessRepository().findDirectHostAccess( + hostId, + userId, + ); - if (access.length === 0) { + if (!access) { return res.status(403).json({ error: "No access to this host" }); } if (credentialId) { - const cred = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .limit(1); + const cred = await createCurrentCredentialRepository().findByIdForUser( + userId, + credentialId, + ); - if (cred.length === 0) { + if (!cred) { return res.status(404).json({ error: "Credential not found" }); } } - await db - .update(hostAccess) - .set({ overrideCredentialId: credentialId || null }) - .where(eq(hostAccess.id, access[0].id)); + await createCurrentRbacAccessRepository().updateHostAccessOverrideCredential( + access.id, + credentialId || null, + ); res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/session-log-routes.ts b/src/backend/database/routes/session-log-routes.ts index 8b39699d..3873aa8f 100644 --- a/src/backend/database/routes/session-log-routes.ts +++ b/src/backend/database/routes/session-log-routes.ts @@ -2,45 +2,72 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import fs from "fs"; import path from "path"; -import { eq, and, desc, lt } from "drizzle-orm"; -import { db } from "../db/index.js"; -import { sessionRecordings, hosts } from "../db/schema.js"; import { apiLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { Request, Response } from "express"; +import { PermissionManager } from "../../utils/permission-manager.js"; +import { + createCurrentSessionRecordingRepository, + createCurrentSettingsRepository, + getCurrentSettingValue, +} from "../repositories/factory.js"; const router = express.Router(); const DATA_DIR = process.env.DATA_DIR ?? "./db/data"; -// Delete session log files and DB rows older than this many days -const LOG_RETENTION_DAYS = 30; +const permissionManager = PermissionManager.getInstance(); + +function isAllowedRecordingPath(filePath: string): boolean { + const resolved = path.resolve(filePath); + return ["session_logs", "session_recordings"].some((directory) => { + const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`; + return resolved.startsWith(base); + }); +} + +function getRetentionDays(): number { + const envDays = parseInt( + process.env.SESSION_RECORDING_RETENTION_DAYS || "", + 10, + ); + try { + const configured = parseInt( + getCurrentSettingValue("session_recording_retention_days") || "", + 10, + ); + if (configured >= 1 && configured <= 3650) return configured; + } catch { + // use environment/default below + } + return envDays >= 1 && envDays <= 3650 ? envDays : 30; +} + +async function canAccessRecording( + userId: string, + ownerId: string, +): Promise { + return userId === ownerId || permissionManager.isAdmin(userId); +} async function pruneOldLogs(): Promise { try { const cutoff = new Date( - Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000, + Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000, ).toISOString(); - const old = await db - .select({ - id: sessionRecordings.id, - recordingPath: sessionRecordings.recordingPath, - }) - .from(sessionRecordings) - .where(lt(sessionRecordings.startedAt, cutoff)); + const sessionRecordingRepository = + createCurrentSessionRecordingRepository(); + const old = await sessionRecordingRepository.listPathsOlderThan(cutoff); for (const row of old) { if (row.recordingPath) { const resolved = path.resolve(row.recordingPath); - const allowed = path.resolve(DATA_DIR, "session_logs"); - if (resolved.startsWith(allowed) && fs.existsSync(resolved)) { + if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) { await fs.promises.unlink(resolved).catch(() => {}); } } - await db - .delete(sessionRecordings) - .where(eq(sessionRecordings.id, row.id)); + await sessionRecordingRepository.deleteById(row.id); } if (old.length > 0) { @@ -81,22 +108,10 @@ const authenticateJWT = authManager.createAuthMiddleware(); router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select({ - id: sessionRecordings.id, - hostId: sessionRecordings.hostId, - userId: sessionRecordings.userId, - startedAt: sessionRecordings.startedAt, - endedAt: sessionRecordings.endedAt, - duration: sessionRecordings.duration, - recordingPath: sessionRecordings.recordingPath, - hostName: hosts.name, - hostIp: hosts.ip, - }) - .from(sessionRecordings) - .leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id)) - .where(eq(sessionRecordings.userId, userId)) - .orderBy(desc(sessionRecordings.startedAt)); + const rows = + await createCurrentSessionRecordingRepository().listByUserIdWithHost( + userId, + ); const records = rows.map((row) => { let sizeBytes: number | null = null; @@ -120,6 +135,45 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { } }); +router.get( + "/retention", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + if (!(await permissionManager.isAdmin(userId))) { + return res.status(403).json({ error: "Admin access required" }); + } + res.json({ retentionDays: getRetentionDays() }); + }, +); + +router.put( + "/retention", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + if (!(await permissionManager.isAdmin(userId))) { + return res.status(403).json({ error: "Admin access required" }); + } + const retentionDays = Number(req.body?.retentionDays); + if ( + !Number.isInteger(retentionDays) || + retentionDays < 1 || + retentionDays > 3650 + ) { + return res + .status(400) + .json({ error: "Retention must be between 1 and 3650 days" }); + } + await createCurrentSettingsRepository().upsert( + "session_recording_retention_days", + String(retentionDays), + ); + void pruneOldLogs(); + res.json({ retentionDays }); + }, +); + /** * @openapi * /session_logs/{id}: @@ -150,16 +204,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => { if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const rows = await db - .select() - .from(sessionRecordings) - .where( - and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ) - .limit(1); + const row = await createCurrentSessionRecordingRepository().findByIdForUser( + userId, + id, + ); - if (rows.length === 0) return res.status(404).json({ error: "Not found" }); - res.json({ log: rows[0] }); + if (!row) return res.status(404).json({ error: "Not found" }); + res.json({ log: row }); } catch (error) { apiLogger.error("Failed to fetch session log", error, { operation: "session_log_get", @@ -205,27 +256,20 @@ router.get( if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const rows = await db - .select({ recordingPath: sessionRecordings.recordingPath }) - .from(sessionRecordings) - .where( - and( - eq(sessionRecordings.id, id), - eq(sessionRecordings.userId, userId), - ), - ) - .limit(1); + const row = + await createCurrentSessionRecordingRepository().findPathByIdForUser( + userId, + id, + ); - if (rows.length === 0) - return res.status(404).json({ error: "Not found" }); + if (!row) return res.status(404).json({ error: "Not found" }); - const filePath = rows[0].recordingPath; + const filePath = row.recordingPath; if (!filePath) return res.status(404).json({ error: "No recording file" }); const resolvedPath = path.resolve(filePath); - const allowedBase = path.resolve(DATA_DIR, "session_logs"); - if (!resolvedPath.startsWith(allowedBase)) { + if (!isAllowedRecordingPath(resolvedPath)) { return res.status(403).json({ error: "Forbidden" }); } @@ -233,8 +277,17 @@ router.get( return res.status(404).json({ error: "File not found" }); } - const content = await fs.promises.readFile(resolvedPath, "utf-8"); - res.type("text/plain").send(content); + const content = await fs.promises.readFile(resolvedPath); + const format = + (row as { format?: string | null }).format ?? + (row.recordingPath?.endsWith(".cast") ? "asciicast" : "text"); + const contentType = + format === "guacamole" + ? "application/vnd.apache.guacamole.recording" + : format === "asciicast" + ? "application/x-asciicast" + : "text/plain"; + res.type(contentType).send(content); } catch (error) { apiLogger.error("Failed to read session log content", error, { operation: "session_log_content", @@ -276,28 +329,22 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const rows = await db - .select({ recordingPath: sessionRecordings.recordingPath }) - .from(sessionRecordings) - .where( - and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ) - .limit(1); + const sessionRecordingRepository = + createCurrentSessionRecordingRepository(); + const row = await sessionRecordingRepository.findPathByIdForUser( + userId, + id, + ); - if (rows.length === 0) return res.status(404).json({ error: "Not found" }); + if (!row) return res.status(404).json({ error: "Not found" }); - const filePath = rows[0].recordingPath; + const filePath = row.recordingPath; - await db - .delete(sessionRecordings) - .where( - and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ); + await sessionRecordingRepository.deleteForUser(userId, id); if (filePath) { const resolvedPath = path.resolve(filePath); - const allowedBase = path.resolve(DATA_DIR, "session_logs"); - if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) { + if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) { await fs.promises.unlink(resolvedPath).catch(() => {}); } } diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 316dd6e9..2fbc4068 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -1,21 +1,18 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - snippets, - snippetFolders, - snippetAccess, - users, - userRoles, -} from "../db/schema.js"; -import { eq, and, desc, asc, sql, or, isNull, gte } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { extractSnippetReorderUpdates } from "./snippets-reorder.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js"; +import { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentSnippetRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; const router = express.Router(); @@ -24,38 +21,12 @@ function isNonEmptyString(val: unknown): val is string { } async function getUserRoleIds(userId: string): Promise { - const rows = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - - return rows.map((row) => row.roleId); + return createCurrentRoleRepository().listUserRoleIds(userId); } -function roleIdFilter(roleIds: number[]) { - if (roleIds.length === 0) { - return undefined; - } - - return sql`${snippetAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})`; -} - -function activeSnippetAccessFilter(userId: string, roleIds: number[]) { - const roleFilter = roleIdFilter(roleIds); - const targetFilter = roleFilter - ? or(eq(snippetAccess.userId, userId), roleFilter) - : eq(snippetAccess.userId, userId); - - return and( - targetFilter, - or( - isNull(snippetAccess.expiresAt), - gte(snippetAccess.expiresAt, new Date().toISOString()), - ), - ); +async function getActorUsername(userId: string): Promise { + const user = await createCurrentUserRepository().findById(userId); + return user?.username ?? userId; } function sortSnippets< @@ -73,41 +44,21 @@ function sortSnippets< } async function getAccessibleSnippet(snippetId: number, userId: string) { - const owned = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const owned = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (owned.length > 0) { - return owned[0]; + if (owned) { + return owned; } const roleIds = await getUserRoleIds(userId); - const shared = await db - .select({ - id: snippets.id, - userId: snippets.userId, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - order: snippets.order, - createdAt: snippets.createdAt, - updatedAt: snippets.updatedAt, - hostFilter: snippets.hostFilter, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .where( - and( - eq(snippetAccess.snippetId, snippetId), - activeSnippetAccessFilter(userId, roleIds), - ), - ) - .limit(1); - - return shared[0] ?? null; + return createCurrentRbacAccessRepository().findAccessibleSharedSnippet( + snippetId, + userId, + roleIds, + ); } const authManager = AuthManager.getInstance(); @@ -143,11 +94,7 @@ router.get( } try { - const result = await db - .select() - .from(snippetFolders) - .where(eq(snippetFolders.userId, userId)) - .orderBy(asc(snippetFolders.name)); + const result = await createCurrentSnippetRepository().listFolders(userId); res.json(result); } catch (err) { @@ -206,38 +153,26 @@ router.post( } try { - const existing = await db - .select() - .from(snippetFolders) - .where( - and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ); + const created = await createCurrentSnippetRepository().createFolder( + userId, + name, + color, + icon, + ); - if (existing.length > 0) { + if (!created) { return res .status(409) .json({ error: "Folder with this name already exists" }); } - const insertData = { - userId, - name: name.trim(), - color: color?.trim() || null, - icon: icon?.trim() || null, - }; - - const result = await db - .insert(snippetFolders) - .values(insertData) - .returning(); - authLogger.success(`Snippet folder created: ${name} by user ${userId}`, { operation: "snippet_folder_create_success", userId, name, }); - res.status(201).json(result[0]); + res.status(201).json(created); } catch (err) { authLogger.error("Failed to create snippet folder", err); res.status(500).json({ @@ -302,51 +237,19 @@ router.put( } try { - const existing = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, decodeURIComponent(name)), - ), + const decodedName = decodeURIComponent(name); + const updated = + await createCurrentSnippetRepository().updateFolderMetadata( + userId, + decodedName, + color, + icon, ); - if (existing.length === 0) { + if (!updated) { return res.status(404).json({ error: "Folder not found" }); } - const updateFields: Partial<{ - color: string | null; - icon: string | null; - updatedAt: ReturnType; - }> = { - updatedAt: sql`CURRENT_TIMESTAMP`, - }; - - if (color !== undefined) updateFields.color = color?.trim() || null; - if (icon !== undefined) updateFields.icon = icon?.trim() || null; - - await db - .update(snippetFolders) - .set(updateFields) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, decodeURIComponent(name)), - ), - ); - - const updated = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, decodeURIComponent(name)), - ), - ); - authLogger.success( `Snippet folder metadata updated: ${name} by user ${userId}`, { @@ -356,7 +259,7 @@ router.put( }, ); - res.json(updated[0]); + res.json(updated); } catch (err) { authLogger.error("Failed to update snippet folder metadata", err); res.status(500).json({ @@ -418,51 +321,22 @@ router.put( } try { - const existing = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, oldName), - ), - ); + const result = await createCurrentSnippetRepository().renameFolder( + userId, + oldName, + newName, + ); - if (existing.length === 0) { + if (result.status === "missing") { return res.status(404).json({ error: "Folder not found" }); } - const nameExists = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, newName), - ), - ); - - if (nameExists.length > 0) { + if (result.status === "conflict") { return res .status(409) .json({ error: "Folder with new name already exists" }); } - await db - .update(snippetFolders) - .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, oldName), - ), - ); - - await db - .update(snippets) - .set({ folder: newName }) - .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); - authLogger.success( `Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`, { @@ -526,21 +400,7 @@ router.delete( try { const folderName = decodeURIComponent(name); - await db - .update(snippets) - .set({ folder: null }) - .where( - and(eq(snippets.userId, userId), eq(snippets.folder, folderName)), - ); - - await db - .delete(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, folderName), - ), - ); + await createCurrentSnippetRepository().deleteFolder(userId, folderName); authLogger.success( `Snippet folder deleted: ${folderName} by user ${userId}`, @@ -623,29 +483,10 @@ router.put( } try { - for (const update of snippetUpdates) { - const { id, order, folder } = update; - - if (!id || order === undefined) { - continue; - } - - const updateFields: Partial<{ - order: number; - folder: string | null; - }> = { - order, - }; - - if (folder !== undefined) { - updateFields.folder = folder?.trim() || null; - } - - await db - .update(snippets) - .set(updateFields) - .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); - } + await createCurrentSnippetRepository().reorderSnippets( + userId, + snippetUpdates, + ); authLogger.success(`Snippets reordered by user ${userId}`, { operation: "snippet_reorder_success", @@ -720,47 +561,25 @@ router.post( } const { Client } = await import("ssh2"); - const { hosts, sshCredentials } = await import("../db/schema.js"); + const repository = createCurrentHostResolutionRepository(); + const host = await repository.findHostById(parseInt(hostId), userId); - const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); - - const hostResult = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); - - if (hostResult.length === 0) { + if (!host || host.userId !== userId) { return res.status(404).json({ error: "Host not found" }); } - const host = hostResult[0]; - let password = host.password; let privateKey = host.key; let passphrase = host.keyPassword; let authType = host.authType; if (host.credentialId) { - const credResult = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", + const cred = await repository.findCredentialByIdForUser( + host.credentialId as number, userId, ); - if (credResult.length > 0) { - const cred = credResult[0]; + if (cred) { authType = (cred.authType || authType) as string; password = (cred.password || undefined) as string | undefined; privateKey = (cred.privateKey || cred.key || undefined) as @@ -774,12 +593,11 @@ router.post( let output = ""; let errorOutput = ""; - /* eslint-disable no-async-promise-executor */ const executePromise = new Promise<{ success: boolean; output: string; error?: string; - }>(async (resolve, reject) => { + }>((resolve, reject) => { const timeout = setTimeout(() => { conn.end(); reject(new Error("Command execution timeout (30s)")); @@ -888,14 +706,6 @@ router.post( if (passphrase) { config.passphrase = passphrase; } - } else if (authType === "agent") { - const result = await applyAgentAuth( - config, - host.terminalConfig as Record | string | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } } else if (password) { config.password = password; } else if (privateKey) { @@ -911,7 +721,6 @@ router.post( conn.connect(config); }); - /* eslint-enable no-async-promise-executor */ const result = await executePromise; @@ -964,17 +773,9 @@ router.get( } try { - const allSnippets = await db - .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy(asc(snippets.folder), asc(snippets.order)); - - const allFolders = await db - .select() - .from(snippetFolders) - .where(eq(snippetFolders.userId, userId)) - .orderBy(asc(snippetFolders.name)); + const snippetRepository = createCurrentSnippetRepository(); + const allSnippets = await snippetRepository.listSnippetsForExport(userId); + const allFolders = await snippetRepository.listFoldersForExport(userId); const exportedSnippets = allSnippets.map((s) => ({ name: s.name, @@ -1057,131 +858,13 @@ router.post( .json({ error: "snippets or folders array is required" }); } - const results = { - snippetsImported: 0, - snippetsSkipped: 0, - snippetsUpdated: 0, - foldersImported: 0, - foldersSkipped: 0, - failed: 0, - errors: [] as string[], - }; - try { - if (Array.isArray(foldersToImport)) { - for (const folder of foldersToImport) { - if (!isNonEmptyString(folder.name)) { - results.failed++; - results.errors.push(`Folder missing name`); - continue; - } - - const existing = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, folder.name.trim()), - ), - ) - .limit(1); - - if (existing.length > 0) { - results.foldersSkipped++; - continue; - } - - await db.insert(snippetFolders).values({ - userId, - name: folder.name.trim(), - color: folder.color?.trim() || null, - icon: folder.icon?.trim() || null, - }); - results.foldersImported++; - } - } - - if (Array.isArray(snippetsToImport)) { - for (let i = 0; i < snippetsToImport.length; i++) { - const s = snippetsToImport[i]; - - if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) { - results.failed++; - results.errors.push( - `Snippet ${i + 1}: name and content are required`, - ); - continue; - } - - const folderVal = s.folder?.trim() || null; - - const existing = await db - .select() - .from(snippets) - .where( - and( - eq(snippets.userId, userId), - eq(snippets.name, s.name.trim()), - folderVal - ? eq(snippets.folder, folderVal) - : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, - ), - ) - .limit(1); - - if (existing.length > 0) { - if (!overwrite) { - results.snippetsSkipped++; - continue; - } - - await db - .update(snippets) - .set({ - content: s.content.trim(), - description: s.description?.trim() || null, - folder: folderVal, - order: - typeof s.order === "number" ? s.order : existing[0].order, - hostFilter: s.hostFilter || null, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where( - and( - eq(snippets.id, existing[0].id), - eq(snippets.userId, userId), - ), - ); - results.snippetsUpdated++; - continue; - } - - const maxOrderResult = await db - .select({ maxOrder: sql`MAX(${snippets.order})` }) - .from(snippets) - .where( - and( - eq(snippets.userId, userId), - folderVal - ? eq(snippets.folder, folderVal) - : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, - ), - ); - const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; - - await db.insert(snippets).values({ - userId, - name: s.name.trim(), - content: s.content.trim(), - description: s.description?.trim() || null, - folder: folderVal, - order: typeof s.order === "number" ? s.order : maxOrder + 1, - hostFilter: s.hostFilter || null, - }); - results.snippetsImported++; - } - } + const results = await createCurrentSnippetRepository().bulkImport( + userId, + snippetsToImport, + foldersToImport, + !!overwrite, + ); authLogger.success(`Snippets bulk-imported by user ${userId}`, { operation: "snippet_bulk_import", @@ -1226,37 +909,15 @@ router.get( } try { - const ownedSnippets = await db - .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy( - sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, - asc(snippets.folder), - asc(snippets.order), - desc(snippets.updatedAt), - ); + const ownedSnippets = + await createCurrentSnippetRepository().listOwnedSnippets(userId); const roleIds = await getUserRoleIds(userId); - const sharedSnippets = await db - .select({ - id: snippets.id, - userId: snippets.userId, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - order: snippets.order, - createdAt: snippets.createdAt, - updatedAt: snippets.updatedAt, - ownerUsername: users.username, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .innerJoin(users, eq(snippets.userId, users.id)) - .where(activeSnippetAccessFilter(userId, roleIds)); + const sharedSnippets = + await createCurrentRbacAccessRepository().listVisibleSharedSnippets( + userId, + roleIds, + ); const visibleSnippets = new Map>(); for (const snippet of ownedSnippets) { @@ -1396,61 +1057,38 @@ router.post( } try { - let snippetOrder = order; - if (snippetOrder === undefined || snippetOrder === null) { - const folderValue = folder?.trim() || ""; - const maxOrderResult = await db - .select({ maxOrder: sql`MAX(${snippets.order})` }) - .from(snippets) - .where( - and( - eq(snippets.userId, userId), - folderValue - ? eq(snippets.folder, folderValue) - : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, - ), - ); - const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; - snippetOrder = maxOrder + 1; - } - - const insertData = { + const result = await createCurrentSnippetRepository().createSnippet( userId, - name: name.trim(), - content: content.trim(), - description: description?.trim() || null, - folder: folder?.trim() || null, - order: snippetOrder, - hostFilter: hostFilter ? JSON.stringify(hostFilter) : null, - }; - - const result = await db.insert(snippets).values(insertData).returning(); + { + name, + content, + description, + folder, + order, + hostFilter, + }, + ); databaseLogger.info("Command snippet created", { operation: "snippet_create", userId, - snippetId: result[0].id, + snippetId: result.id, name, }); const { ipAddress: scIp, userAgent: scUa } = getRequestMeta(req); - const scActor = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: scActor[0]?.username ?? userId, + username: await getActorUsername(userId), action: "create_snippet", resourceType: "snippet", - resourceId: String(result[0].id), + resourceId: String(result.id), resourceName: name, ipAddress: scIp, userAgent: scUa, success: true, }); - res.status(201).json(result[0]); + res.status(201).json(result); } catch (err) { authLogger.error("Failed to create snippet", err); res.status(500).json({ @@ -1516,75 +1154,36 @@ router.put( } try { - const existing = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + const snippetId = parseInt(id); + const result = await createCurrentSnippetRepository().updateSnippet( + userId, + snippetId, + updateData, + ); - if (existing.length === 0) { + if (!result) { return res.status(404).json({ error: "Snippet not found" }); } - - const updateFields: Partial<{ - updatedAt: ReturnType; - name: string; - content: string; - description: string | null; - folder: string | null; - order: number; - hostFilter: string | null; - }> = { - updatedAt: sql`CURRENT_TIMESTAMP`, - }; - - if (updateData.name !== undefined) - updateFields.name = updateData.name.trim(); - if (updateData.content !== undefined) - updateFields.content = updateData.content.trim(); - if (updateData.description !== undefined) - updateFields.description = updateData.description?.trim() || null; - if (updateData.folder !== undefined) - updateFields.folder = updateData.folder?.trim() || null; - if (updateData.order !== undefined) updateFields.order = updateData.order; - if (updateData.hostFilter !== undefined) - updateFields.hostFilter = updateData.hostFilter - ? JSON.stringify(updateData.hostFilter) - : null; - - await db - .update(snippets) - .set(updateFields) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); - - const updated = await db - .select() - .from(snippets) - .where(eq(snippets.id, parseInt(id))); databaseLogger.info("Command snippet updated", { operation: "snippet_update", userId, - snippetId: parseInt(id), + snippetId, }); const { ipAddress: suIp, userAgent: suUa } = getRequestMeta(req); - const suActor = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: suActor[0]?.username ?? userId, + username: await getActorUsername(userId), action: "update_snippet", resourceType: "snippet", resourceId: id, - resourceName: existing[0].name, + resourceName: result.existing.name, ipAddress: suIp, userAgent: suUa, success: true, }); - res.json(updated[0]); + res.json(result.updated); } catch (err) { authLogger.error("Failed to update snippet", err); res.status(500).json({ @@ -1632,37 +1231,30 @@ router.delete( } try { - const existing = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + const snippetId = parseInt(id); + const existing = await createCurrentSnippetRepository().deleteSnippet( + userId, + snippetId, + ); - if (existing.length === 0) { + if (!existing) { return res.status(404).json({ error: "Snippet not found" }); } - await db - .delete(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); databaseLogger.info("Command snippet deleted", { operation: "snippet_delete", userId, - snippetId: parseInt(id), + snippetId, }); const { ipAddress: sdIp, userAgent: sdUa } = getRequestMeta(req); - const sdActor = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: sdActor[0]?.username ?? userId, + username: await getActorUsername(userId), action: "delete_snippet", resourceType: "snippet", resourceId: id, - resourceName: existing[0].name, + resourceName: existing.name, ipAddress: sdIp, userAgent: sdUa, success: true, diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index 7a2d068f..fc29e095 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -3,12 +3,10 @@ import type { OIDCProviderConfig, } from "../../../types/index.js"; import type { Router } from "express"; -import { db } from "../db/index.js"; -import { ssoProviders } from "../db/schema.js"; -import { eq, asc } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { SSOProviderType } from "../../../types/index.js"; +import { createCurrentSsoProviderRepository } from "../repositories/factory.js"; import { getOIDCConfigFromEnv } from "./user-oidc-utils.js"; const authManager = AuthManager.getInstance(); @@ -91,7 +89,6 @@ function applyProviderDefaults( } export function registerSSOProviderRoutes(router: Router): void { - const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); /** @@ -108,16 +105,8 @@ export function registerSSOProviderRoutes(router: Router): void { */ router.get("/sso-providers", async (_req, res) => { try { - const providers = await db - .select({ - id: ssoProviders.id, - name: ssoProviders.name, - type: ssoProviders.type, - displayOrder: ssoProviders.displayOrder, - }) - .from(ssoProviders) - .where(eq(ssoProviders.enabled, true)) - .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + const providers = + await createCurrentSsoProviderRepository().listEnabledPublic(); // If no DB providers exist, synthesize one from env vars so SSO login // remains available when configured purely via environment variables. @@ -150,10 +139,7 @@ export function registerSSOProviderRoutes(router: Router): void { router.get("/sso-providers/admin", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select() - .from(ssoProviders) - .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + const rows = await createCurrentSsoProviderRepository().listAll(); const result = rows.map((row) => ({ ...row, @@ -273,16 +259,13 @@ export function registerSSOProviderRoutes(router: Router): void { tempId, ); - const [inserted] = await db - .insert(ssoProviders) - .values({ - name: name.trim(), - type, - enabled, - displayOrder, - config: encryptedConfig, - }) - .returning(); + const inserted = await createCurrentSsoProviderRepository().create({ + name: name.trim(), + type, + enabled, + displayOrder, + config: encryptedConfig, + }); authLogger.info("SSO provider created", { operation: "sso_provider_create", @@ -327,12 +310,9 @@ export function registerSSOProviderRoutes(router: Router): void { return res.status(400).json({ error: "Invalid provider ID" }); } try { - const existing = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (existing.length === 0) { + const providerRepository = createCurrentSsoProviderRepository(); + const existing = await providerRepository.findById(providerId); + if (!existing) { return res.status(404).json({ error: "SSO provider not found" }); } @@ -350,10 +330,10 @@ export function registerSSOProviderRoutes(router: Router): void { config?: Record; }; - let encryptedConfig = existing[0].config; + let encryptedConfig = existing.config; if (rawConfig !== undefined) { const existingDecrypted = decryptProviderConfig( - existing[0].config, + existing.config, userId, ); const mergedConfig = { @@ -369,18 +349,18 @@ export function registerSSOProviderRoutes(router: Router): void { ); } - const [updated] = await db - .update(ssoProviders) - .set({ - ...(name !== undefined ? { name: name.trim() } : {}), - ...(type !== undefined ? { type } : {}), - ...(enabled !== undefined ? { enabled } : {}), - ...(displayOrder !== undefined ? { displayOrder } : {}), - config: encryptedConfig, - updatedAt: new Date().toISOString(), - }) - .where(eq(ssoProviders.id, providerId)) - .returning(); + const updated = await providerRepository.update(providerId, { + ...(name !== undefined ? { name: name.trim() } : {}), + ...(type !== undefined ? { type } : {}), + ...(enabled !== undefined ? { enabled } : {}), + ...(displayOrder !== undefined ? { displayOrder } : {}), + config: encryptedConfig, + updatedAt: new Date().toISOString(), + }); + + if (!updated) { + return res.status(404).json({ error: "SSO provider not found" }); + } authLogger.info("SSO provider updated", { operation: "sso_provider_update", @@ -426,27 +406,21 @@ export function registerSSOProviderRoutes(router: Router): void { return res.status(400).json({ error: "Invalid provider ID" }); } try { - const existing = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (existing.length === 0) { + const providerRepository = createCurrentSsoProviderRepository(); + const existing = await providerRepository.findById(providerId); + if (!existing) { return res.status(404).json({ error: "SSO provider not found" }); } - const associatedUsers = db.$client - .prepare( - "SELECT COUNT(*) as count FROM users WHERE sso_provider_id = ?", - ) - .get(providerId) as { count: number }; - if (associatedUsers.count > 0) { + const associatedUserCount = + await providerRepository.countUsersByProviderId(providerId); + if (associatedUserCount > 0) { return res.status(409).json({ - error: `Cannot delete provider: ${associatedUsers.count} user(s) are associated with it`, + error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`, }); } - await db.delete(ssoProviders).where(eq(ssoProviders.id, providerId)); + await providerRepository.delete(providerId); authLogger.info("SSO provider deleted", { operation: "sso_provider_delete", userId, diff --git a/src/backend/database/routes/tailscale-routes.ts b/src/backend/database/routes/tailscale-routes.ts index 423c460f..924fc2b5 100644 --- a/src/backend/database/routes/tailscale-routes.ts +++ b/src/backend/database/routes/tailscale-routes.ts @@ -1,8 +1,8 @@ import { Router } from "express"; import type { RequestHandler, Router as ExpressRouter } from "express"; -import { db } from "../db/index.js"; import { apiLogger } from "../../utils/logger.js"; import { getProxyAgent } from "../../utils/proxy-agent.js"; +import { createCurrentSettingsRepository } from "../repositories/factory.js"; interface TailscaleDevice { id: string; @@ -56,11 +56,9 @@ export function registerTailscaleRoutes( */ router.get("/devices", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'") - .get() as { value: string } | undefined; - - const apiKey = row?.value ?? ""; + const apiKey = + (await createCurrentSettingsRepository().get("tailscale_api_key")) ?? + ""; if (!apiKey) { return res.json({ devices: [], hasApiKey: false }); } diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts index 0bb8154e..cdc3799b 100644 --- a/src/backend/database/routes/terminal.ts +++ b/src/backend/database/routes/terminal.ts @@ -1,11 +1,13 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { commandHistory, hosts } from "../db/schema.js"; -import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { + createCurrentCommandHistoryRepository, + createCurrentHostResolutionRepository, + createCurrentSettingsRepository, +} from "../repositories/factory.js"; const router = express.Router(); @@ -88,12 +90,11 @@ router.post( }); } - const globalEnabledRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'command_history_enabled'", - ) - .get() as { value: string } | undefined; - if (globalEnabledRow && globalEnabledRow.value === "false") { + const globalEnabled = await createCurrentSettingsRepository().getBoolean( + "command_history_enabled", + true, + ); + if (!globalEnabled) { return res.status(201).json({ id: 0, userId, @@ -103,12 +104,12 @@ router.post( }); } - const hostRecord = await db - .select({ enableCommandHistory: hosts.enableCommandHistory }) - .from(hosts) - .where(eq(hosts.id, parseInt(hostId, 10))) - .limit(1); - if (hostRecord.length > 0 && hostRecord[0].enableCommandHistory === false) { + const hostRecord = + await createCurrentHostResolutionRepository().findHostById( + parseInt(hostId, 10), + userId, + ); + if (hostRecord?.enableCommandHistory === false) { return res.status(201).json({ id: 0, userId, @@ -119,18 +120,13 @@ router.post( } try { - const insertData = { + const result = await createCurrentCommandHistoryRepository().create( userId, - hostId: parseInt(hostId, 10), - command: trimmedCommand, - }; + parseInt(hostId, 10), + trimmedCommand, + ); - const result = await db - .insert(commandHistory) - .values(insertData) - .returning(); - - res.status(201).json(result[0]); + res.status(201).json(result); } catch (err) { authLogger.error("Failed to save command to history", err); res.status(500).json({ @@ -182,23 +178,11 @@ router.get( } try { - const result = await db - .select({ - command: commandHistory.command, - maxExecutedAt: sql`MAX(${commandHistory.executedAt})`, - }) - .from(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostIdNum), - ), - ) - .groupBy(commandHistory.command) - .orderBy(desc(sql`MAX(${commandHistory.executedAt})`)) - .limit(500); - - const uniqueCommands = result.map((r) => r.command); + const uniqueCommands = + await createCurrentCommandHistoryRepository().listUniqueCommandsForHost( + userId, + hostIdNum, + ); res.json(uniqueCommands); } catch (err) { @@ -258,15 +242,11 @@ router.post( try { const hostIdNum = parseInt(hostId, 10); - await db - .delete(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostIdNum), - eq(commandHistory.command, command.trim()), - ), - ); + await createCurrentCommandHistoryRepository().deleteCommandForHost( + userId, + hostIdNum, + command.trim(), + ); res.json({ success: true }); } catch (err) { @@ -317,14 +297,10 @@ router.delete( } try { - await db - .delete(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostIdNum), - ), - ); + await createCurrentCommandHistoryRepository().deleteByUserAndHost( + userId, + hostIdNum, + ); databaseLogger.info("Terminal history cleared", { operation: "terminal_history_clear", userId, @@ -360,20 +336,18 @@ router.get( authenticateJWT, async (_req: Request, res: Response) => { try { - const timeoutRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", - ) - .get() as { value: string } | undefined; - const enabledRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'", - ) - .get() as { value: string } | undefined; + const settings = createCurrentSettingsRepository(); + const timeoutValue = await settings.get( + "terminal_session_timeout_minutes", + ); + const enabled = await settings.getBoolean( + "terminal_session_persistence_enabled", + true, + ); res.json({ - timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30, - enabled: enabledRow ? enabledRow.value === "true" : true, + timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30, + enabled, }); } catch (err) { authLogger.error("Failed to fetch session settings", err); @@ -429,20 +403,19 @@ router.post( } try { + const settings = createCurrentSettingsRepository(); if (timeoutMinutes !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)", - ) - .run(String(timeoutMinutes)); + await settings.set( + "terminal_session_timeout_minutes", + String(timeoutMinutes), + ); } if (enabled !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)", - ) - .run(String(enabled)); + await settings.set( + "terminal_session_persistence_enabled", + String(enabled), + ); } res.json({ success: true }); diff --git a/src/backend/database/routes/termix-id.ts b/src/backend/database/routes/termix-id.ts index 3a069c0f..ed8e72db 100644 --- a/src/backend/database/routes/termix-id.ts +++ b/src/backend/database/routes/termix-id.ts @@ -1,21 +1,17 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import type { Request, Response } from "express"; -import { db } from "../db/index.js"; import { - termixIdentities, - termixIdentityKeys, - sshCredentials, - termixIdentityCa, - users, -} from "../db/schema.js"; -import { and, eq, asc } from "drizzle-orm"; + createCurrentCredentialRepository, + createCurrentTermixIdentityRepository, + createCurrentTermixIdentityCaRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; // ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named // export, so we use a default import (esModuleInterop) and read `.utils` off it. import ssh2 from "ssh2"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js"; import { @@ -38,17 +34,6 @@ const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]); // Max stored length for a free-text description. const MAX_DESCRIPTION_LENGTH = 500; -// Decrypted ssh_credentials fields this route reads. A type alias (not an -// interface) so it satisfies the generic bound on SimpleDBOps.select. -type CredentialRow = { - name?: string | null; - authType?: string | null; - publicKey?: string | null; - privateKey?: string | null; - key?: string | null; - keyPassword?: string | null; -}; - function cleanDescription(value: string | null | undefined): string | null { if (typeof value !== "string") return null; return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null; @@ -93,29 +78,18 @@ async function derivePublicFromPrivate( } async function getIdentityForUser(userId: string) { - const rows = await db - .select() - .from(termixIdentities) - .where(eq(termixIdentities.userId, userId)) - .limit(1); - return rows[0] ?? null; + return createCurrentTermixIdentityRepository().findIdentityForUser(userId); } // Resolve the real username for audit_logs (the column expects a username, not // the user id), matching how the credentials/snippets routes log. async function getActorUsername(userId: string): Promise { - const rows = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - return rows[0]?.username ?? userId; + const user = await createCurrentUserRepository().findById(userId); + return user?.username ?? userId; } -// --------------------------------------------------------------------------- // Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a // small HTML viewer for browsers, keyed by handle, with optional / filter. -// --------------------------------------------------------------------------- // Never let an intermediary/CDN cache a public resolver feed (a disabled/removed // key could keep being served after revocation), and keep it out of search @@ -137,26 +111,19 @@ async function resolveHandle(req: Request, res: Response) { } try { - const identity = await db - .select() - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); + const identity = + await createCurrentTermixIdentityRepository().findIdentityByHandle( + handle, + ); - if (identity.length === 0) { + if (!identity) { return res.status(404).type("text/plain").send("Not found\n"); } - let keys = await db - .select() - .from(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.identityId, identity[0].id), - eq(termixIdentityKeys.enabled, true), - ), - ) - .orderBy(asc(termixIdentityKeys.id)); + let keys = + await createCurrentTermixIdentityRepository().listEnabledKeysByIdentityId( + identity.id, + ); if (algoFilter) { keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter)); @@ -280,25 +247,23 @@ async function caResolver(req: Request, res: Response) { return res.status(404).type("text/plain").send("Not found\n"); } try { - const identity = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - if (identity.length === 0) { + const identity = + await createCurrentTermixIdentityRepository().findIdentityByHandle( + handle, + ); + if (!identity) { return res.status(404).type("text/plain").send("Not found\n"); } - const ca = await db - .select({ publicKey: termixIdentityCa.publicKey }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity[0].id)) - .limit(1); + const ca = + await createCurrentTermixIdentityCaRepository().findPublicByIdentityId( + identity.id, + ); res.setHeader("Content-Type", "text/plain; charset=utf-8"); - if (ca.length === 0) { + if (!ca) { return res.status(404).send("No CA configured\n"); } - return res.send(`${ca[0].publicKey} termix-id-ca@${handle}\n`); + return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`); } catch (err) { authLogger.error("Termix ID CA resolve failed", err); return res.status(500).type("text/plain").send("Internal Server Error\n"); @@ -309,9 +274,7 @@ router.get("/u/:handle/ca", caResolver); router.get("/u/:handle", resolveHandle); router.get("/u/:handle/:algo", resolveHandle); -// --------------------------------------------------------------------------- // Authenticated management API. -// --------------------------------------------------------------------------- /** * @openapi @@ -332,11 +295,10 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { if (!identity) { return res.json({ identity: null, keys: [] }); } - const keys = await db - .select() - .from(termixIdentityKeys) - .where(eq(termixIdentityKeys.identityId, identity.id)) - .orderBy(asc(termixIdentityKeys.id)); + const keys = + await createCurrentTermixIdentityRepository().listKeysByIdentityId( + identity.id, + ); res.json({ identity: { ...identity, @@ -377,12 +339,9 @@ router.get( return res.json({ available: false, valid: false }); } try { - const existing = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - res.json({ available: existing.length === 0, valid: true }); + const taken = + await createCurrentTermixIdentityRepository().isHandleTaken(handle); + res.json({ available: !taken, valid: true }); } catch (err) { authLogger.error("Failed to check Termix ID handle", err); res.status(500).json({ error: "Failed to check handle" }); @@ -433,19 +392,17 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { }); } - const taken = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - if (taken.length > 0) { + const termixIdentityRepository = createCurrentTermixIdentityRepository(); + const taken = await termixIdentityRepository.isHandleTaken(handle); + if (taken) { return res.status(409).json({ error: "Handle already taken" }); } - const inserted = await db - .insert(termixIdentities) - .values({ userId, handle, description }) - .returning(); + const inserted = await termixIdentityRepository.createIdentity({ + userId, + handle, + description, + }); databaseLogger.info("Termix ID created", { operation: "termix_id_create", @@ -459,14 +416,14 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { username: await getActorUsername(userId), action: "create_termix_id", resourceType: "termix_id", - resourceId: String(inserted[0].id), + resourceId: String(inserted.id), resourceName: handle, ipAddress, userAgent, success: true, }); - res.status(201).json(inserted[0]); + res.status(201).json(inserted); } catch (err) { // The check-then-insert above is not atomic; the UNIQUE constraints on // handle and user_id are the real guard, so map a violation to 409. @@ -516,12 +473,9 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid handle" }); } if (handle !== identity.handle) { - const taken = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - if (taken.length > 0) { + const taken = + await createCurrentTermixIdentityRepository().isHandleTaken(handle); + if (taken) { return res.status(409).json({ error: "Handle already taken" }); } } @@ -532,11 +486,11 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { updates.description = cleanDescription(req.body.description); } - const updated = await db - .update(termixIdentities) - .set({ ...updates, updatedAt: new Date().toISOString() }) - .where(eq(termixIdentities.userId, userId)) - .returning(); + const updated = + await createCurrentTermixIdentityRepository().updateIdentityForUser( + userId, + { ...updates, updatedAt: new Date().toISOString() }, + ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -545,7 +499,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { action: "update_termix_id", resourceType: "termix_id", resourceId: String(identity.id), - resourceName: updated[0]?.handle ?? identity.handle, + resourceName: updated?.handle ?? identity.handle, details: updates.handle && updates.handle !== identity.handle ? `renamed ${identity.handle} -> ${updates.handle}` @@ -555,7 +509,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { success: true, }); - res.json(updated[0]); + res.json(updated); } catch (err) { if (isUniqueConstraintError(err)) { return res.status(409).json({ error: "Handle already taken" }); @@ -584,9 +538,7 @@ router.delete("/", authenticateJWT, async (req: Request, res: Response) => { if (!identity) { return res.status(404).json({ error: "No Termix ID found" }); } - await db - .delete(termixIdentities) - .where(eq(termixIdentities.userId, userId)); + await createCurrentTermixIdentityRepository().deleteIdentityForUser(userId); databaseLogger.info("Termix ID deleted", { operation: "termix_id_delete", @@ -665,23 +617,14 @@ router.post( let resolvedLabel = label; if (credentialId) { - const credResult = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); - if (credResult.length === 0) { + const cred = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, + ); + if (!cred) { return res.status(404).json({ error: "Credential not found" }); } - const cred = credResult[0]; // The UI only offers key credentials, but the UI is not authoritative — // reject non-key credentials server-side before treating cred.key as a // private key. @@ -721,31 +664,25 @@ router.post( } // Dedupe within this identity by normalized " ". - const existing = await db - .select({ - id: termixIdentityKeys.id, - publicKey: termixIdentityKeys.publicKey, - }) - .from(termixIdentityKeys) - .where(eq(termixIdentityKeys.identityId, identity.id)); + const termixIdentityRepository = createCurrentTermixIdentityRepository(); + const existing = await termixIdentityRepository.listKeysByIdentityId( + identity.id, + ); if (existing.some((k) => k.publicKey === parsed.normalized)) { return res.status(409).json({ error: "This key is already published" }); } - const inserted = await db - .insert(termixIdentityKeys) - .values({ - identityId: identity.id, - userId, - publicKey: parsed.normalized, - keyType: parsed.type, - algorithm: parsed.algorithm, - label: resolvedLabel, - comment: parsed.comment || null, - source, - credentialId: credentialId || null, - }) - .returning(); + const inserted = await termixIdentityRepository.createKey({ + identityId: identity.id, + userId, + publicKey: parsed.normalized, + keyType: parsed.type, + algorithm: parsed.algorithm, + label: resolvedLabel, + comment: parsed.comment || null, + source, + credentialId: credentialId || null, + }); databaseLogger.info("Termix ID key added", { operation: "termix_id_key_add", @@ -760,7 +697,7 @@ router.post( username: await getActorUsername(userId), action: "add_termix_id_key", resourceType: "termix_id_key", - resourceId: String(inserted[0].id), + resourceId: String(inserted.id), resourceName: identity.handle, details: `${parsed.algorithm} (${source})`, ipAddress, @@ -768,7 +705,7 @@ router.post( success: true, }); - res.status(201).json(inserted[0]); + res.status(201).json(inserted); } catch (err) { authLogger.error("Failed to add Termix ID key", err); res.status(500).json({ error: "Failed to add key" }); @@ -863,34 +800,31 @@ router.post( usageCount: 0, lastUsed: null, }; - const created = (await SimpleDBOps.insert( - sshCredentials, - "ssh_credentials", - credData, - userId, - )) as typeof credData & { id: number }; + const created = + await createCurrentCredentialRepository().createEncryptedForUser( + userId, + credData, + ); credentialId = created.id; } - // There is no single transaction here (SimpleDBOps.insert is async and - // better-sqlite3 transactions are sync), so if publishing the key fails + // There is no single transaction here because credential encryption is async, + // so if publishing the key fails // after the vault credential was created, compensate by deleting it to // avoid leaving an orphaned credential behind. + const termixIdentityRepository = createCurrentTermixIdentityRepository(); const runInsert = () => - db - .insert(termixIdentityKeys) - .values({ - identityId: identity.id, - userId, - publicKey: parsed.normalized, - keyType: parsed.type, - algorithm: parsed.algorithm, - label: label || `Generated ${parsed.algorithm}`, - comment: parsed.comment || null, - source: "generated", - credentialId, - }) - .returning(); + termixIdentityRepository.createKey({ + identityId: identity.id, + userId, + publicKey: parsed.normalized, + keyType: parsed.type, + algorithm: parsed.algorithm, + label: label || `Generated ${parsed.algorithm}`, + comment: parsed.comment || null, + source: "generated", + credentialId, + }); let inserted: Awaited>; try { @@ -898,14 +832,10 @@ router.post( } catch (insertErr) { if (credentialId !== null) { try { - await db - .delete(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ); + await createCurrentCredentialRepository().deleteForUser( + userId, + credentialId, + ); } catch { // best-effort cleanup } @@ -926,7 +856,7 @@ router.post( username: await getActorUsername(userId), action: "generate_termix_id_key", resourceType: "termix_id_key", - resourceId: String(inserted[0].id), + resourceId: String(inserted.id), resourceName: identity.handle, details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`, ipAddress, @@ -937,7 +867,7 @@ router.post( // The private key is also returned once so the user can download it; when // saveCredential is true it additionally lives (encrypted) in the vault. res.status(201).json({ - key: inserted[0], + key: inserted, privateKey: pair.private, publicKey: parsed.normalized, credentialId, @@ -997,17 +927,13 @@ router.patch( ? req.body.label.trim() || null : null; } - const updated = await db - .update(termixIdentityKeys) - .set(updates) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .returning(); - if (updated.length === 0) { + const updated = + await createCurrentTermixIdentityRepository().updateKeyForUser( + userId, + id, + updates, + ); + if (!updated) { return res.status(404).json({ error: "Key not found" }); } @@ -1018,7 +944,7 @@ router.patch( action: "update_termix_id_key", resourceType: "termix_id_key", resourceId: String(id), - resourceName: String(updated[0].label ?? updated[0].algorithm), + resourceName: String(updated.label ?? updated.algorithm), details: updates.enabled !== undefined ? `enabled: ${updates.enabled}` @@ -1028,7 +954,7 @@ router.patch( success: true, }); - res.json(updated[0]); + res.json(updated); } catch (err) { authLogger.error("Failed to update Termix ID key", err); res.status(500).json({ error: "Failed to update key" }); @@ -1064,16 +990,12 @@ router.delete( return res.status(400).json({ error: "Invalid key id" }); } try { - const deleted = await db - .delete(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .returning(); - if (deleted.length === 0) { + const deleted = + await createCurrentTermixIdentityRepository().deleteKeyForUser( + userId, + id, + ); + if (!deleted) { return res.status(404).json({ error: "Key not found" }); } @@ -1098,9 +1020,7 @@ router.delete( }, ); -// --------------------------------------------------------------------------- // Certificate authority — central revocation (rotate) + expiry (validity). -// --------------------------------------------------------------------------- type CaRow = { id: number; @@ -1122,15 +1042,12 @@ async function getCaForUser( userId: string, identityId: number, ): Promise { - const rows = await SimpleDBOps.select( - db - .select() - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identityId)), - "termix_identity_ca", - userId, + return ( + (await createCurrentTermixIdentityCaRepository().findDecryptedByIdentityId( + userId, + identityId, + )) ?? undefined ); - return rows[0]; } /** @@ -1150,19 +1067,15 @@ router.get("/ca", authenticateJWT, async (req: Request, res: Response) => { try { const identity = await getIdentityForUser(userId); if (!identity) return res.json({ ca: null }); - const ca = await db - .select({ - publicKey: termixIdentityCa.publicKey, - validityDays: termixIdentityCa.validityDays, - }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .limit(1); - if (ca.length === 0) return res.json({ ca: null }); + const ca = + await createCurrentTermixIdentityCaRepository().findPublicByIdentityId( + identity.id, + ); + if (!ca) return res.json({ ca: null }); res.json({ ca: { - publicKey: ca[0].publicKey, - validityDays: ca[0].validityDays, + publicKey: ca.publicKey, + validityDays: ca.validityDays, resolverPath: `/termix-id/u/${identity.handle}/ca`, }, }); @@ -1205,29 +1118,21 @@ router.post( .status(400) .json({ error: "Create a Termix ID handle first" }); } - const existing = await db - .select({ id: termixIdentityCa.id }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .limit(1); - if (existing.length > 0) { + const caRepository = createCurrentTermixIdentityCaRepository(); + const existing = await caRepository.findPublicByIdentityId(identity.id); + if (existing) { return res.status(409).json({ error: "CA already exists" }); } const validityDays = clampValidityDays(req.body?.validityDays, 90); const generated = generateCa(); - await SimpleDBOps.insert( - termixIdentityCa, - "termix_identity_ca", - { - identityId: identity.id, - userId, - publicKey: generated.publicKeyLine, - privateKey: generated.privateKeyPem, - validityDays, - }, + await caRepository.createEncryptedForUser(userId, { + identityId: identity.id, userId, - ); + publicKey: generated.publicKeyLine, + privateKey: generated.privateKeyPem, + validityDays, + }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -1283,37 +1188,25 @@ router.post( const identity = await getIdentityForUser(userId); if (!identity) return res.status(404).json({ error: "No Termix ID found" }); - const existing = await db - .select({ - id: termixIdentityCa.id, - validityDays: termixIdentityCa.validityDays, - }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .limit(1); - if (existing.length === 0) { + const caRepository = createCurrentTermixIdentityCaRepository(); + const existing = await caRepository.findPublicByIdentityId(identity.id); + if (!existing) { return res.status(404).json({ error: "No CA to rotate" }); } const validityDays = clampValidityDays( req.body?.validityDays, - existing[0].validityDays, + existing.validityDays, ); const generated = generateCa(); // Rotating invalidates every previously issued certificate — this IS the // central revocation mechanism. - await SimpleDBOps.update( - termixIdentityCa, - "termix_identity_ca", - eq(termixIdentityCa.identityId, identity.id), - { - publicKey: generated.publicKeyLine, - privateKey: generated.privateKeyPem, - validityDays, - updatedAt: new Date().toISOString(), - }, - userId, - ); + await caRepository.updateEncryptedForIdentity(userId, identity.id, { + publicKey: generated.publicKeyLine, + privateKey: generated.privateKeyPem, + validityDays, + updatedAt: new Date().toISOString(), + }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -1356,11 +1249,11 @@ router.delete("/ca", authenticateJWT, async (req: Request, res: Response) => { try { const identity = await getIdentityForUser(userId); if (!identity) return res.status(404).json({ error: "No Termix ID found" }); - const deleted = await db - .delete(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .returning(); - if (deleted.length === 0) { + const deleted = + await createCurrentTermixIdentityCaRepository().deleteByIdentityId( + identity.id, + ); + if (!deleted) { return res.status(404).json({ error: "No CA to delete" }); } @@ -1424,20 +1317,13 @@ router.post( return res.status(400).json({ error: "Invalid key id" }); } try { - const keyRows = await db - .select() - .from(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .limit(1); - if (keyRows.length === 0) { + const key = await createCurrentTermixIdentityRepository().findKeyForUser( + userId, + id, + ); + if (!key) { return res.status(404).json({ error: "Key not found" }); } - const key = keyRows[0]; if (!ed25519RawFromLine(key.publicKey)) { return res.status(400).json({ error: "Certificates are only supported for Ed25519 keys", @@ -1523,22 +1409,10 @@ router.get( try { const identity = await getIdentityForUser(userId); if (!identity) return res.json({ credentialIds: [] }); - const keys = await db - .select({ credentialId: termixIdentityKeys.credentialId }) - .from(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.identityId, identity.id), - eq(termixIdentityKeys.enabled, true), - ), + const credentialIds = + await createCurrentTermixIdentityRepository().listLinkedCredentialIds( + identity.id, ); - const credentialIds = [ - ...new Set( - keys - .map((k) => k.credentialId) - .filter((cid): cid is number => cid !== null), - ), - ]; res.json({ credentialIds }); } catch (err) { authLogger.error("Failed to fetch linked credential IDs", err); diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index d6630e1b..083c37c6 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -1,18 +1,35 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq, and } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users, roles, userRoles } from "../db/schema.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { + createCurrentRoleRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import type { + UserRecord, + UserRepository, +} from "../repositories/user-repository.js"; function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } +async function getUserByPreferredIdentifier( + userRepository: UserRepository, + userId: string | null, + username: string | null, +): Promise { + return userId + ? userRepository.findById(userId) + : userRepository.findByUsername(username!); +} + export function registerUserAdminRoutes( router: Router, authenticateJWT: RequestHandler, @@ -35,15 +52,11 @@ export function registerUserAdminRoutes( */ router.get("/list", authenticateJWT, async (req, res) => { try { - const allUsers = await db - .select({ - id: users.id, - username: users.username, - isAdmin: users.isAdmin, - isOidc: users.isOidc, - passwordHash: users.passwordHash, - }) - .from(users); + const userRepository = createCurrentUserRepository(); + const requester = await userRepository.findById( + (req as AuthenticatedRequest).userId, + ); + const allUsers = await userRepository.listAll(); res.json({ users: allUsers.map((u) => ({ @@ -52,6 +65,14 @@ export function registerUserAdminRoutes( is_admin: u.isAdmin, is_oidc: u.isOidc, password_hash: u.passwordHash ? "set" : null, + // Management-only details stay admin-eyes-only; regular users hit + // this route to pick sharing targets. + ...(requester?.isAdmin + ? { + data_unlocked: DataCrypto.canUserAccessData(u.id), + totp_enabled: !!u.totpEnabled, + } + : {}), })), }); } catch (err) { @@ -108,95 +129,51 @@ export function registerUserAdminRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(userId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } - const targetUser = await db - .select() - .from(users) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ) - .limit(1); - if (!targetUser || targetUser.length === 0) { + const targetUser = await getUserByPreferredIdentifier( + userRepository, + resolvedUserId, + resolvedUsername, + ); + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - if (targetUser[0].isAdmin) { + if (targetUser.isAdmin) { return res.status(400).json({ error: "User is already an admin" }); } - await db - .update(users) - .set({ isAdmin: true }) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ); + await userRepository.update(targetUser.id, { isAdmin: true }); try { - const targetId = targetUser[0].id; - const adminRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "admin")) - .limit(1); - const userRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "user")) - .limit(1); - if (adminRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, adminRole[0].id), - ), - ); - await db.insert(userRoles).values({ - userId: targetId, - roleId: adminRole[0].id, - grantedBy: userId, - }); - } - if (userRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, userRole[0].id), - ), - ); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId: targetUser.id, + addRoleName: "admin", + removeRoleName: "user", + grantedBy: userId, + }); } catch (roleError) { authLogger.error("Failed to sync admin role on make-admin", roleError, { operation: "make_admin_role_sync", - userId: targetUser[0].id, + userId: targetUser.id, }); } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("make_admin_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist admin promotion to disk", saveError, { operation: "make_admin_save_failed", - userId: targetUser[0].id, - username: targetUser[0].username, + userId: targetUser.id, + username: targetUser.username, }, ); } @@ -204,29 +181,24 @@ export function registerUserAdminRoutes( authLogger.info("Admin privileges granted", { operation: "admin_grant", adminId: userId, - targetUserId: targetUser[0].id, - targetUsername: targetUser[0].username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, }); const { ipAddress, userAgent } = getRequestMeta(req); - const adminUserRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: adminUserRecord[0]?.username ?? userId, + username: adminUser.username ?? userId, action: "make_admin", resourceType: "user", - resourceId: targetUser[0].id, - resourceName: targetUser[0].username, + resourceId: targetUser.id, + resourceName: targetUser.username, ipAddress, userAgent, success: true, }); - res.json({ message: `User ${targetUser[0].username} is now an admin` }); + res.json({ message: `User ${targetUser.username} is now an admin` }); } catch (err) { authLogger.error("Failed to make user admin", err); res.status(500).json({ error: "Failed to make user admin" }); @@ -281,135 +253,86 @@ export function registerUserAdminRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(userId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } if ( - (resolvedUserId && adminUser[0].id === resolvedUserId) || - (resolvedUsername && adminUser[0].username === resolvedUsername) + (resolvedUserId && adminUser.id === resolvedUserId) || + (resolvedUsername && adminUser.username === resolvedUsername) ) { return res .status(400) .json({ error: "Cannot remove your own admin status" }); } - const targetUser = await db - .select() - .from(users) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ) - .limit(1); - if (!targetUser || targetUser.length === 0) { + const targetUser = await getUserByPreferredIdentifier( + userRepository, + resolvedUserId, + resolvedUsername, + ); + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - if (!targetUser[0].isAdmin) { + if (!targetUser.isAdmin) { return res.status(400).json({ error: "User is not an admin" }); } - await db - .update(users) - .set({ isAdmin: false }) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ); + await userRepository.update(targetUser.id, { isAdmin: false }); try { - const targetId = targetUser[0].id; - const adminRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "admin")) - .limit(1); - const userRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "user")) - .limit(1); - if (adminRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, adminRole[0].id), - ), - ); - } - if (userRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, userRole[0].id), - ), - ); - await db.insert(userRoles).values({ - userId: targetId, - roleId: userRole[0].id, - grantedBy: userId, - }); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId: targetUser.id, + addRoleName: "user", + removeRoleName: "admin", + grantedBy: userId, + }); } catch (roleError) { authLogger.error( "Failed to sync user role on remove-admin", roleError, { operation: "remove_admin_role_sync", - userId: targetUser[0].id, + userId: targetUser.id, }, ); } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("remove_admin_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist admin removal to disk", saveError, { operation: "remove_admin_save_failed", - userId: targetUser[0].id, - username: targetUser[0].username, + userId: targetUser.id, + username: targetUser.username, }); } authLogger.info("Admin privileges revoked", { operation: "admin_revoke", adminId: userId, - targetUserId: targetUser[0].id, - targetUsername: targetUser[0].username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, }); const { ipAddress, userAgent } = getRequestMeta(req); - const adminUserRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: adminUserRecord[0]?.username ?? userId, + username: adminUser.username ?? userId, action: "remove_admin", resourceType: "user", - resourceId: targetUser[0].id, - resourceName: targetUser[0].username, + resourceId: targetUser.id, + resourceName: targetUser.username, ipAddress, userAgent, success: true, }); res.json({ - message: `Admin status removed from ${targetUser[0].username}`, + message: `Admin status removed from ${targetUser.username}`, }); } catch (err) { authLogger.error("Failed to remove admin status", err); @@ -450,13 +373,12 @@ export function registerUserAdminRoutes( */ router.post("/admin-create", authenticateJWT, async (req, res) => { const adminId = (req as AuthenticatedRequest).userId; + const userRepository = createCurrentUserRepository(); + let adminUser: UserRecord | null = null; try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } } catch (err) { @@ -473,55 +395,39 @@ export function registerUserAdminRoutes( } try { - const existing = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (existing && existing.length > 0) { + const existing = await userRepository.findByUsername(username); + if (existing) { return res.status(409).json({ error: "Username already exists" }); } const password_hash = await bcrypt.hash(password, 10); const id = nanoid(); - db.$client.transaction(() => { - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - username, - password_hash, - 0, - 0, - "", - "", - "", - "", - "", - "", - "", - "openid email profile", - null, - 0, - null, - ); - })(); + await userRepository.create({ + id, + username, + passwordHash: password_hash, + isAdmin: false, + isOidc: false, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); try { - const userRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "user")) - .limit(1); - if (userRole.length > 0) { - await db.insert(userRoles).values({ - userId: id, - roleId: userRole[0].id, - grantedBy: adminId, - }); - } + await createCurrentRoleRepository().assignRoleNameToUser({ + userId: id, + roleName: "user", + grantedBy: adminId, + }); } catch (roleError) { authLogger.error( "Failed to assign default role during admin create", @@ -537,7 +443,7 @@ export function registerUserAdminRoutes( try { await authManager.registerUser(id, password); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, id)); + await userRepository.delete(id); authLogger.error( "Failed to setup user encryption during admin create, rolled back", encryptionError, @@ -549,8 +455,7 @@ export function registerUserAdminRoutes( } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("admin_create_user_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist admin-created user to disk", @@ -570,14 +475,9 @@ export function registerUserAdminRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const adminRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, adminId)) - .limit(1); await logAudit({ userId: adminId, - username: adminRecord[0]?.username ?? adminId, + username: adminUser.username ?? adminId, action: "create_user", resourceType: "user", resourceId: id, @@ -596,4 +496,299 @@ export function registerUserAdminRoutes( res.status(500).json({ error: "Failed to create user" }); } }); + + /** + * @openapi + * /users/admin/reset-password: + * post: + * summary: Reset a user's password (admin only) + * description: > + * Resets another user's password. Data is preserved for users whose + * encryption key has been migrated to the system wrap. Users who never + * logged in since the encryption upgrade require confirmDataWipe, + * which deletes their encrypted data. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - newPassword + * properties: + * userId: + * type: string + * username: + * type: string + * newPassword: + * type: string + * confirmDataWipe: + * type: boolean + * responses: + * 200: + * description: Password reset; dataWiped indicates whether encrypted data was deleted. + * 400: + * description: Missing or invalid parameters. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 409: + * description: Reset would wipe the user's data and confirmDataWipe was not set. + * 500: + * description: Failed to reset password. + */ + router.post("/admin/reset-password", authenticateJWT, async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + const { userId: targetUserId, username, newPassword } = req.body; + const resolvedUserId = isNonEmptyString(targetUserId) + ? targetUserId.trim() + : null; + const resolvedUsername = isNonEmptyString(username) + ? username.trim() + : null; + + if (!resolvedUserId && !resolvedUsername) { + return res.status(400).json({ error: "User ID or username is required" }); + } + if (!isNonEmptyString(newPassword)) { + return res.status(400).json({ error: "New password is required" }); + } + + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const targetUser = await getUserByPreferredIdentifier( + userRepository, + resolvedUserId, + resolvedUsername, + ); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + if (targetUser.isOidc && !targetUser.passwordHash) { + return res.status(400).json({ + error: "This user authenticates through an external provider", + }); + } + + const { resetUserPassword } = + await import("./user-password-reset-routes.js"); + const outcome = await resetUserPassword(AuthManager.getInstance(), { + userId: targetUser.id, + username: targetUser.username, + newPassword, + confirmDataWipe: req.body?.confirmDataWipe === true, + }); + + if (outcome.status === "wipe_confirmation_required") { + return res.status(409).json({ + error: + "This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets.", + code: "DATA_WIPE_REQUIRED", + }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: adminId, + username: adminUser.username ?? adminId, + action: "admin_reset_password", + resourceType: "user", + resourceId: targetUser.id, + resourceName: targetUser.username, + ipAddress, + userAgent, + success: true, + }); + + await DatabaseSaveTrigger.forceSave("admin_password_reset"); + + res.json({ + message: "Password reset", + dataWiped: outcome.dataWiped, + }); + } catch (err) { + authLogger.error("Failed to reset user password", err); + res.status(500).json({ error: "Failed to reset password" }); + } + }); + + /** + * @openapi + * /users/admin/totp/disable: + * post: + * summary: Disable a user's TOTP (admin only) + * description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * responses: + * 200: + * description: TOTP disabled for the user. + * 400: + * description: User ID is required or TOTP is not enabled. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 500: + * description: Failed to disable TOTP. + */ + router.post("/admin/totp/disable", authenticateJWT, async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + const { userId: targetUserId } = req.body; + + if (!isNonEmptyString(targetUserId)) { + return res.status(400).json({ error: "User ID is required" }); + } + + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const targetUser = await userRepository.findById(targetUserId.trim()); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + if (!targetUser.totpEnabled) { + return res + .status(400) + .json({ error: "TOTP is not enabled for this user" }); + } + + await userRepository.update(targetUser.id, { + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); + + try { + await DatabaseSaveTrigger.forceSave("admin_disable_totp"); + } catch (saveError) { + authLogger.error("Failed to persist TOTP disable to disk", saveError, { + operation: "admin_disable_totp_save_failed", + userId: targetUser.id, + }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: adminId, + username: adminUser.username ?? adminId, + action: "admin_disable_totp", + resourceType: "user", + resourceId: targetUser.id, + resourceName: targetUser.username, + ipAddress, + userAgent, + success: true, + }); + + res.json({ message: "TOTP disabled" }); + } catch (err) { + authLogger.error("Failed to disable TOTP for user", err); + res.status(500).json({ error: "Failed to disable TOTP" }); + } + }); + + /** + * @openapi + * /users/admin/export/{userId}: + * get: + * summary: Export a user's data (admin only) + * description: Downloads a JSON export of another user's data (hosts, credentials, file manager bookmarks). Secrets are decrypted server-side, so handle the file carefully. + * tags: + * - Users + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: JSON export of the user's data. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 423: + * description: The user's data stays locked until their next login. + * 500: + * description: Failed to export user data. + */ + router.get("/admin/export/:userId", authenticateJWT, async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + const targetUserId = Array.isArray(req.params.userId) + ? req.params.userId[0] + : req.params.userId; + + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const targetUser = await userRepository.findById(targetUserId); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + + if (!DataCrypto.canUserAccessData(targetUser.id)) { + return res.status(423).json({ + error: "Target user's data stays locked until their next login", + code: "TARGET_DATA_LOCKED", + }); + } + + const { UserDataExport } = + await import("../../utils/user-data-export.js"); + const exportData = await UserDataExport.exportUserData(targetUser.id, { + format: "plaintext", + includeCredentials: true, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: adminId, + username: adminUser.username ?? adminId, + action: "admin_export_user_data", + resourceType: "user", + resourceId: targetUser.id, + resourceName: targetUser.username, + ipAddress, + userAgent, + success: true, + }); + + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename="termix-user-${targetUser.username}-export.json"`, + ); + res.json(exportData); + } catch (err) { + authLogger.error("Failed to export user data", err); + res.status(500).json({ error: "Failed to export user data" }); + } + }); } diff --git a/src/backend/database/routes/user-api-key-routes.ts b/src/backend/database/routes/user-api-key-routes.ts index bb3ada5d..f09b3a2a 100644 --- a/src/backend/database/routes/user-api-key-routes.ts +++ b/src/backend/database/routes/user-api-key-routes.ts @@ -3,11 +3,12 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; -import { eq } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { apiKeys, users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentApiKeyRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; export function registerUserApiKeyRoutes( router: Router, @@ -56,6 +57,8 @@ export function registerUserApiKeyRoutes( router.post("/api-keys", requireAdmin, async (req, res) => { try { const { name, userId: targetUserId, expiresAt } = req.body; + const apiKeyRepository = createCurrentApiKeyRepository(); + const userRepository = createCurrentUserRepository(); if (typeof name !== "string" || !name.trim()) { return res.status(400).json({ error: "name is required" }); @@ -64,12 +67,8 @@ export function registerUserApiKeyRoutes( return res.status(400).json({ error: "userId is required" }); } - const targetUser = await db - .select() - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); - if (targetUser.length === 0) { + const targetUser = await userRepository.findById(targetUserId); + if (!targetUser) { return res.status(404).json({ error: "Target user not found" }); } @@ -93,7 +92,7 @@ export function registerUserApiKeyRoutes( const keyId = nanoid(); const now = new Date().toISOString(); - await db.insert(apiKeys).values({ + await apiKeyRepository.create({ id: keyId, userId: targetUserId, name: name.trim(), @@ -105,26 +104,21 @@ export function registerUserApiKeyRoutes( isActive: true, }); - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - const actorId = (req as AuthenticatedRequest).userId; const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, actorId)) - .limit(1); + const actorRecord = actorId + ? await userRepository.findById(actorId) + : null; await logAudit({ userId: actorId, - username: actorRecord[0]?.username ?? actorId, + username: actorRecord?.username ?? actorId, action: "create_api_key", resourceType: "api_key", resourceId: keyId, resourceName: name.trim(), details: JSON.stringify({ targetUserId, - targetUsername: targetUser[0].username, + targetUsername: targetUser.username, }), ipAddress, userAgent, @@ -135,7 +129,7 @@ export function registerUserApiKeyRoutes( id: keyId, name: name.trim(), userId: targetUserId, - username: targetUser[0].username, + username: targetUser.username, tokenPrefix, createdAt: now, expiresAt: expiresAtValue, @@ -165,21 +159,7 @@ export function registerUserApiKeyRoutes( */ router.get("/api-keys", requireAdmin, async (_req, res) => { try { - const keys = await db - .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); + const keys = await createCurrentApiKeyRepository().listAllWithUsers(); return res.json({ apiKeys: keys }); } catch (err) { @@ -216,36 +196,26 @@ export function registerUserApiKeyRoutes( router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => { try { const keyId = String(req.params.keyId); + const apiKeyRepository = createCurrentApiKeyRepository(); + const userRepository = createCurrentUserRepository(); - const existing = await db - .select() - .from(apiKeys) - .where(eq(apiKeys.id, keyId)) - .limit(1); - - if (existing.length === 0) { + const deleted = await apiKeyRepository.delete(keyId); + if (!deleted) { return res.status(404).json({ error: "API key not found" }); } - await db.delete(apiKeys).where(eq(apiKeys.id, keyId)); - - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - const actorId = (req as AuthenticatedRequest).userId; const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, actorId)) - .limit(1); + const actorRecord = actorId + ? await userRepository.findById(actorId) + : null; await logAudit({ userId: actorId, - username: actorRecord[0]?.username ?? actorId, + username: actorRecord?.username ?? actorId, action: "delete_api_key", resourceType: "api_key", resourceId: keyId, - resourceName: existing[0].name, + resourceName: deleted.name, ipAddress, userAgent, success: true, diff --git a/src/backend/database/routes/user-oidc-account-routes.ts b/src/backend/database/routes/user-oidc-account-routes.ts index 5151a1e7..29fd6bd4 100644 --- a/src/backend/database/routes/user-oidc-account-routes.ts +++ b/src/backend/database/routes/user-oidc-account-routes.ts @@ -1,10 +1,9 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; +import { createCurrentUserRepository } from "../repositories/factory.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; type UserOidcAccountRoutesDeps = { @@ -62,42 +61,30 @@ export function registerUserOidcAccountRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminUserId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminUserId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Admin access required" }); } - const oidcUserRecords = await db - .select() - .from(users) - .where(eq(users.id, oidcUserId)); - if (!oidcUserRecords || oidcUserRecords.length === 0) { + const oidcUser = await userRepository.findById(oidcUserId); + if (!oidcUser) { return res.status(404).json({ error: "OIDC user not found" }); } - const oidcUser = oidcUserRecords[0]; - if (!oidcUser.isOidc) { return res.status(400).json({ error: "Source user is not an OIDC user", }); } - const targetUserRecords = await db - .select() - .from(users) - .where(eq(users.username, targetUsername)); - if (!targetUserRecords || targetUserRecords.length === 0) { + const targetUser = await userRepository.findByUsername(targetUsername); + if (!targetUser) { return res .status(404) .json({ error: "Target password user not found" }); } - const targetUser = targetUserRecords[0]; - if (targetUser.isOidc || !targetUser.passwordHash) { return res.status(400).json({ error: "Target user must be a password-based account", @@ -119,58 +106,18 @@ export function registerUserOidcAccountRoutes( adminUserId, }); - await db - .update(users) - .set({ - isOidc: true, - oidcIdentifier: oidcUser.oidcIdentifier, - clientId: oidcUser.clientId, - clientSecret: oidcUser.clientSecret, - issuerUrl: oidcUser.issuerUrl, - authorizationUrl: oidcUser.authorizationUrl, - tokenUrl: oidcUser.tokenUrl, - identifierPath: oidcUser.identifierPath, - namePath: oidcUser.namePath, - scopes: oidcUser.scopes || "openid email profile", - }) - .where(eq(users.id, targetUser.id)); - - try { - await authManager.convertToOIDCEncryption(targetUser.id); - } catch (encryptionError) { - authLogger.error( - "Failed to convert encryption to OIDC during linking", - encryptionError, - { - operation: "link_convert_encryption_failed", - userId: targetUser.id, - }, - ); - await db - .update(users) - .set({ - isOidc: false, - oidcIdentifier: null, - clientId: "", - clientSecret: "", - issuerUrl: "", - authorizationUrl: "", - tokenUrl: "", - identifierPath: "", - namePath: "", - scopes: "openid email profile", - }) - .where(eq(users.id, targetUser.id)); - - return res.status(500).json({ - error: - "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", - details: - encryptionError instanceof Error - ? encryptionError.message - : "Unknown error", - }); - } + await userRepository.update(targetUser.id, { + isOidc: true, + oidcIdentifier: oidcUser.oidcIdentifier, + clientId: oidcUser.clientId, + clientSecret: oidcUser.clientSecret, + issuerUrl: oidcUser.issuerUrl, + authorizationUrl: oidcUser.authorizationUrl, + tokenUrl: oidcUser.tokenUrl, + identifierPath: oidcUser.identifierPath, + namePath: oidcUser.namePath, + scopes: oidcUser.scopes || "openid email profile", + }); await authManager.revokeAllUserSessions(oidcUserId); authManager.logoutUser(oidcUserId); @@ -178,8 +125,7 @@ export function registerUserOidcAccountRoutes( await deleteUserAndRelatedData(oidcUserId); try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist account linking to disk", @@ -265,12 +211,10 @@ export function registerUserOidcAccountRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminUserId)); + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminUserId); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + if (!adminUser?.isAdmin) { authLogger.warn("Non-admin attempted to unlink OIDC from password", { operation: "unlink_oidc_unauthorized", adminUserId, @@ -281,19 +225,13 @@ export function registerUserOidcAccountRoutes( }); } - const targetUserRecords = await db - .select() - .from(users) - .where(eq(users.id, userId)); - - if (!targetUserRecords || targetUserRecords.length === 0) { + const targetUser = await userRepository.findById(userId); + if (!targetUser) { return res.status(404).json({ error: "User not found", }); } - const targetUser = targetUserRecords[0]; - if (!targetUser.isOidc) { return res.status(400).json({ error: "User does not have OIDC authentication enabled", @@ -314,25 +252,21 @@ export function registerUserOidcAccountRoutes( adminUserId, }); - await db - .update(users) - .set({ - isOidc: false, - oidcIdentifier: null, - clientId: "", - clientSecret: "", - issuerUrl: "", - authorizationUrl: "", - tokenUrl: "", - identifierPath: "", - namePath: "", - scopes: "openid email profile", - }) - .where(eq(users.id, targetUser.id)); + await userRepository.update(targetUser.id, { + isOidc: false, + oidcIdentifier: null, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + }); try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save"); } catch (saveError) { authLogger.error( "Failed to save database after unlinking OIDC", diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 85469e03..3b3e66e5 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -1,10 +1,18 @@ import { authLogger } from "../../utils/logger.js"; import type { SSOProviderType } from "../../../types/index.js"; -import { db } from "../db/index.js"; -import { ssoProviders } from "../db/schema.js"; -import { eq } from "drizzle-orm"; import { DataCrypto } from "../../utils/data-crypto.js"; import { Agent } from "undici"; +import { + createCurrentSettingsRepository, + createCurrentSsoProviderRepository, +} from "../repositories/factory.js"; + +const BACKCHANNEL_LOGOUT_EVENT = + "http://schemas.openid.net/event/backchannel-logout"; + +function normalizeIssuer(url: string): string { + return url.trim().replace(/\/+$/, ""); +} export type OIDCConfig = { client_id: string; @@ -311,13 +319,9 @@ export async function loadProviderConfig( } | null> { if (providerId != null) { try { - const rows = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (rows.length > 0) { - const row = rows[0]; + const row = + await createCurrentSsoProviderRepository().findById(providerId); + if (row) { let parsed: Record; try { parsed = JSON.parse(row.config); @@ -367,14 +371,8 @@ export async function loadProviderConfig( // Fallback: first enabled OIDC-type provider in ssoProviders table try { - const rows = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.enabled, true)) - .orderBy(); - const oidcRow = rows.find( - (r) => r.type === "oidc" || r.type === "github" || r.type === "google", - ); + const oidcRow = + await createCurrentSsoProviderRepository().findFirstEnabledOidcLike(); if (oidcRow) { let parsed: Record; try { @@ -399,11 +397,10 @@ export async function loadProviderConfig( // Fallback: legacy settings blob try { - const legacyRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") - .get() as { value: string } | undefined; - if (legacyRow) { - let config = JSON.parse(legacyRow.value) as Record; + const legacyValue = + await createCurrentSettingsRepository().get("oidc_config"); + if (legacyValue) { + let config = JSON.parse(legacyValue) as Record; config = decryptConfigSecret(config); return { config: config as unknown as OIDCConfig, @@ -417,3 +414,99 @@ export async function loadProviderConfig( return null; } + +export async function resolveProviderByIssuer(issuer: string): Promise<{ + config: OIDCConfig; + providerType: SSOProviderType; + providerDbId: number | null; +} | null> { + const target = normalizeIssuer(issuer); + + try { + const rows = await createCurrentSsoProviderRepository().listEnabled(); + for (const row of rows) { + if (!["oidc", "github", "google"].includes(row.type)) continue; + let parsed: Record; + try { + parsed = JSON.parse(row.config); + } catch { + continue; + } + parsed = decryptConfigSecret(parsed); + const providerType = row.type as SSOProviderType; + const config = applyProviderDefaults( + parsed as unknown as OIDCConfig, + providerType, + ); + if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) { + return { config, providerType, providerDbId: row.id }; + } + } + } catch (err) { + authLogger.error("Failed to resolve SSO provider by issuer", err, { + issuer, + }); + } + + const envConfig = getOIDCConfigFromEnv(); + if ( + envConfig?.issuer_url && + normalizeIssuer(envConfig.issuer_url) === target + ) { + return { config: envConfig, providerType: "oidc", providerDbId: null }; + } + + return null; +} + +export type LogoutTokenClaims = { + sub: string | null; + sid: string | null; + jti: string; +}; + +export function validateLogoutTokenClaims( + payload: Record, +): LogoutTokenClaims { + if ("nonce" in payload) { + throw new Error("logout_token must not contain a nonce claim"); + } + + const event = (payload.events as Record | undefined)?.[ + BACKCHANNEL_LOGOUT_EVENT + ]; + if (!event || typeof event !== "object" || Array.isArray(event)) { + throw new Error("logout_token missing back-channel logout event"); + } + + if (!Number.isInteger(payload.iat)) { + throw new Error("logout_token missing iat claim"); + } + + const jti = typeof payload.jti === "string" ? payload.jti.trim() : ""; + if (!jti) { + throw new Error("logout_token missing jti claim"); + } + + const sub = typeof payload.sub === "string" ? payload.sub : null; + const sid = typeof payload.sid === "string" ? payload.sid : null; + if (!sub && !sid) { + throw new Error("logout_token must contain sub and/or sid"); + } + + return { sub, sid, jti }; +} + +export async function validateLogoutToken( + logoutToken: string, + config: OIDCConfig, +): Promise { + const payload = await verifyOIDCToken( + logoutToken, + config.issuer_url, + config.client_id, + config.ca_cert, + ); + + return validateLogoutTokenClaims(payload); +} diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index c2dce5a2..95171a34 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -2,24 +2,20 @@ import type { Router } from "express"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; -import { eq } from "drizzle-orm"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; -import { db } from "../db/index.js"; import { - users, - hosts, - sshCredentials, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - dismissedAlerts, - sshCredentialUsage, - recentActivity, - snippets, - webauthnCredentials, -} from "../db/schema.js"; + createCurrentCredentialRepository, + createCurrentDismissedAlertRepository, + createCurrentFileManagerBookmarkRepository, + createCurrentHostRepository, + createCurrentRecentActivityRepository, + createCurrentSettingsRepository, + createCurrentSnippetRepository, + createCurrentSshCredentialUsageRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; interface UserPasswordResetRoutesDeps { authManager: AuthManager; @@ -29,6 +25,82 @@ function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } +export type PasswordResetOutcome = + | { status: "reset"; dataWiped: false } + | { status: "reset"; dataWiped: true } + | { status: "wipe_confirmation_required" }; + +// Resets a user's password. The DEK is wrapped by the system key, so for any +// user migrated to the v3 wrap this is just a hash update and their data +// survives. Users who never logged in after the encryption upgrade still have +// a password-wrapped DEK that a reset cannot recover; their encrypted data +// must be wiped, which callers have to confirm explicitly. +export async function resetUserPassword( + authManager: AuthManager, + options: { + userId: string; + username: string; + newPassword: string; + confirmDataWipe: boolean; + }, +): Promise { + const { userId, username, newPassword, confirmDataWipe } = options; + const passwordHash = await bcrypt.hash(newPassword, 10); + const userRepository = createCurrentUserRepository(); + + if (authManager.isUserUnlocked(userId)) { + await userRepository.update(userId, { passwordHash }); + await authManager.logoutUser(userId); + + authLogger.success( + `Password reset (data preserved) for user: ${username}`, + { + operation: "password_reset_preserved", + userId, + username, + }, + ); + return { status: "reset", dataWiped: false }; + } + + if (!confirmDataWipe) { + return { status: "wipe_confirmation_required" }; + } + + await userRepository.update(userId, { passwordHash }); + + await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); + await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId); + await createCurrentRecentActivityRepository().deleteByUserId(userId); + await createCurrentDismissedAlertRepository().deleteByUserId(userId); + await createCurrentSnippetRepository().deleteByUserId(userId); + await createCurrentHostRepository().deleteByUserId(userId); + await createCurrentCredentialRepository().deleteByUserId(userId); + + const { UserKeyManager } = await import("../../utils/user-keys.js"); + await UserKeyManager.getInstance().rotateUserDEK(userId); + const { deleteLegacyWraps } = + await import("../../utils/crypto-migration/dek-migration.js"); + await deleteLegacyWraps(userId); + await authManager.logoutUser(userId); + + await userRepository.update(userId, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); + + authLogger.warn( + `Password reset completed for user: ${username}. All encrypted data has been deleted because the old key was unrecoverable.`, + { + operation: "password_reset_data_deleted", + userId, + username, + }, + ); + return { status: "reset", dataWiped: true }; +} + export function registerUserPasswordResetRoutes( router: Router, { authManager }: UserPasswordResetRoutesDeps, @@ -68,14 +140,10 @@ export function registerUserPasswordResetRoutes( const allowed = envVal !== undefined ? envVal.trim().toLowerCase() === "true" - : (() => { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_password_reset'", - ) - .get(); - return row ? (row as { value: string }).value === "true" : true; - })(); + : await createCurrentSettingsRepository().getBoolean( + "allow_password_reset", + true, + ); if (!allowed) { return res .status(403) @@ -95,12 +163,9 @@ export function registerUserPasswordResetRoutes( } try { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); + const user = await createCurrentUserRepository().findByUsername(username); - if (!user || user.length === 0) { + if (!user) { authLogger.warn( `Password reset attempted for non-existent user: ${username}`, ); @@ -110,7 +175,7 @@ export function registerUserPasswordResetRoutes( }); } - if (user[0].isOidc) { + if (user.isOidc) { return res.json({ message: "If the user exists, a password reset code has been generated. Check docker logs for the code.", @@ -120,15 +185,13 @@ export function registerUserPasswordResetRoutes( const resetCode = crypto.randomInt(100000, 1000000).toString(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run( - `reset_code_${username}`, - JSON.stringify({ - code: resetCode, - expiresAt: expiresAt.toISOString(), - }), - ); + await createCurrentSettingsRepository().set( + `reset_code_${username}`, + JSON.stringify({ + code: resetCode, + expiresAt: expiresAt.toISOString(), + }), + ); authLogger.info( `Password reset code generated for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`, @@ -200,10 +263,10 @@ export function registerUserPasswordResetRoutes( loginRateLimiter.recordResetCodeAttempt(username); - const resetDataRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`reset_code_${username}`); - if (!resetDataRow) { + const resetDataValue = await createCurrentSettingsRepository().get( + `reset_code_${username}`, + ); + if (!resetDataValue) { authLogger.warn("Reset code verification failed - no code found", { operation: "reset_code_verify_failed", username, @@ -217,16 +280,14 @@ export function registerUserPasswordResetRoutes( }); } - const resetData = JSON.parse( - (resetDataRow as Record).value as string, - ); + const resetData = JSON.parse(resetDataValue); const now = new Date(); const expiresAt = new Date(resetData.expiresAt); if (now > expiresAt) { - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`reset_code_${username}`); + await createCurrentSettingsRepository().delete( + `reset_code_${username}`, + ); authLogger.warn("Reset code verification failed - code expired", { operation: "reset_code_verify_failed", username, @@ -259,15 +320,13 @@ export function registerUserPasswordResetRoutes( const tempToken = nanoid(); const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run( - `temp_reset_token_${username}`, - JSON.stringify({ - token: tempToken, - expiresAt: tempTokenExpiry.toISOString(), - }), - ); + await createCurrentSettingsRepository().set( + `temp_reset_token_${username}`, + JSON.stringify({ + token: tempToken, + expiresAt: tempTokenExpiry.toISOString(), + }), + ); res.json({ message: "Reset code verified", tempToken }); } catch (err) { @@ -321,23 +380,21 @@ export function registerUserPasswordResetRoutes( } try { - const tempTokenRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`temp_reset_token_${username}`); - if (!tempTokenRow) { + const tempTokenValue = await createCurrentSettingsRepository().get( + `temp_reset_token_${username}`, + ); + if (!tempTokenValue) { return res.status(400).json({ error: "No temporary token found" }); } - const tempTokenData = JSON.parse( - (tempTokenRow as Record).value as string, - ); + const tempTokenData = JSON.parse(tempTokenValue); const now = new Date(); const expiresAt = new Date(tempTokenData.expiresAt); if (now > expiresAt) { - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`temp_reset_token_${username}`); + await createCurrentSettingsRepository().delete( + `temp_reset_token_${username}`, + ); return res.status(400).json({ error: "Temporary token has expired" }); } @@ -345,152 +402,37 @@ export function registerUserPasswordResetRoutes( return res.status(400).json({ error: "Invalid temporary token" }); } - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (!user || user.length === 0) { + const user = await createCurrentUserRepository().findByUsername(username); + if (!user) { return res.status(404).json({ error: "User not found" }); } - const userId = user[0].id; + const userId = user.id; - const password_hash = await bcrypt.hash(newPassword, 10); + const outcome = await resetUserPassword(authManager, { + userId, + username, + newPassword, + confirmDataWipe: req.body?.confirmDataWipe === true, + }); - let userIdFromJwt: string | null = null; - const cookie = req.cookies?.jwt; - let header: string | undefined; - if (req.headers?.authorization?.startsWith("Bearer ")) { - header = req.headers?.authorization?.split(" ")[1]; - } - const token = cookie || header; - - if (token) { - const payload = await authManager.verifyJWTToken(token); - if (payload) { - userIdFromJwt = payload.userId; - } - } - - if (userIdFromJwt === userId) { - try { - const success = await authManager.resetUserPasswordWithPreservedDEK( - userId, - newPassword, - ); - - if (!success) { - throw new Error( - "Failed to re-encrypt user data with new password.", - ); - } - - await db - .update(users) - .set({ passwordHash: password_hash }) - .where(eq(users.id, userId)); - authManager.logoutUser(userId); - authLogger.success( - `Password reset (data preserved) for user: ${username}`, - { - operation: "password_reset_preserved", - userId, - username, - }, - ); - } catch (encryptionError) { - authLogger.error( - "Failed to setup user data encryption after password reset", - encryptionError, - { - operation: "password_reset_encryption_failed_preserved", - userId, - username, - }, - ); - return res.status(500).json({ - error: "Password reset failed. Please contact administrator.", - }); - } - } else { - await db - .update(users) - .set({ passwordHash: password_hash }) - .where(eq(users.username, username)); - - try { - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)); - await db - .delete(recentActivity) - .where(eq(recentActivity.userId, userId)); - await db - .delete(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); - await db.delete(snippets).where(eq(snippets.userId, userId)); - await db.delete(hosts).where(eq(hosts.userId, userId)); - await db - .delete(sshCredentials) - .where(eq(sshCredentials.userId, userId)); - await db - .delete(webauthnCredentials) - .where(eq(webauthnCredentials.userId, userId)); - - await authManager.registerUser(userId, newPassword); - authManager.logoutUser(userId); - - await db - .update(users) - .set({ - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }) - .where(eq(users.id, userId)); - - authLogger.warn( - `Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`, - { - operation: "password_reset_data_deleted", - userId, - username, - }, - ); - } catch (encryptionError) { - authLogger.error( - "Failed to setup user data encryption after password reset", - encryptionError, - { - operation: "password_reset_encryption_failed", - userId, - username, - }, - ); - return res.status(500).json({ - error: "Password reset failed. Please contact administrator.", - }); - } + if (outcome.status === "wipe_confirmation_required") { + return res.status(409).json({ + error: + "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets.", + code: "DATA_WIPE_REQUIRED", + }); } authLogger.success(`Password successfully reset for user: ${username}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`reset_code_${username}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`temp_reset_token_${username}`); + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.delete(`reset_code_${username}`); + await settingsRepository.delete(`temp_reset_token_${username}`); - res.json({ message: "Password has been successfully reset" }); + res.json({ + message: "Password has been successfully reset", + dataWiped: outcome.dataWiped, + }); } catch (err) { authLogger.error("Failed to complete password reset", err); res.status(500).json({ error: "Failed to complete password reset" }); diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 6dbc8d5a..87b5ae73 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -1,17 +1,19 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { userPreferences } from "../db/schema.js"; -import { eq } from "drizzle-orm"; import type { Request, Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { createCurrentUserPreferenceRepository } from "../repositories/factory.js"; +import type { + UserPreferenceRecord, + UserPreferenceUpdate, +} from "../repositories/user-preference-repository.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); -const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ +const pickPreferences = (row?: UserPreferenceRecord | null) => ({ reopenTabsOnLogin: row?.reopenTabsOnLogin ?? false, theme: row?.theme ?? null, fontSize: row?.fontSize ?? null, @@ -105,16 +107,13 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ * type: string * nullable: true */ -router.get("/", authenticateJWT, (req: Request, res: Response) => { +router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, userId)) - .all(); + const preferences = + await createCurrentUserPreferenceRepository().findByUserId(userId); - return res.json(pickPreferences(rows[0])); + return res.json(pickPreferences(preferences)); } catch (e) { databaseLogger.error("Failed to get user preferences", e, { operation: "get_user_preferences", @@ -180,7 +179,7 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => { * 200: * description: Preferences updated successfully. */ -router.put("/", authenticateJWT, (req: Request, res: Response) => { +router.put("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { reopenTabsOnLogin, @@ -224,7 +223,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { statusColorScheme?: string | null; }; - const updates: Partial = { + const updates: UserPreferenceUpdate = { updatedAt: new Date().toISOString(), }; @@ -301,25 +300,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { } try { - const existing = db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, userId)) - .all(); - - if (existing.length === 0) { - db.insert(userPreferences) - .values({ - userId, - ...updates, - }) - .run(); - } else { - db.update(userPreferences) - .set(updates) - .where(eq(userPreferences.userId, userId)) - .run(); - } + await createCurrentUserPreferenceRepository().upsert(userId, updates); return res.json({ success: true, ...updates }); } catch (e) { diff --git a/src/backend/database/routes/user-session-routes.ts b/src/backend/database/routes/user-session-routes.ts index 7e9ee6c0..e832a8c4 100644 --- a/src/backend/database/routes/user-session-routes.ts +++ b/src/backend/database/routes/user-session-routes.ts @@ -1,11 +1,12 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { sessions, users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentSessionRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; type UserSessionRoutesDeps = { authenticateJWT: RequestHandler; @@ -38,39 +39,38 @@ export function registerUserSessionRoutes( const currentSessionId = authReq.sessionId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; let sessionList; if (userRecord.isAdmin) { sessionList = await authManager.getAllSessions(); - - const enrichedSessions = await Promise.all( - sessionList.map(async (session) => { - const sessionUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, session.userId)) - .limit(1); - - return { - id: session.id, - userId: session.userId, - username: sessionUser[0]?.username || "Unknown", - deviceType: session.deviceType, - deviceInfo: session.deviceInfo, - createdAt: session.createdAt, - expiresAt: session.expiresAt, - lastActiveAt: session.lastActiveAt, - isRevoked: session.isRevoked, - isCurrentSession: session.id === currentSessionId, - }; - }), + const sessionUsers = await userRepository.listByIds( + sessionList.map((session) => session.userId), ); + const usernamesById = new Map( + sessionUsers.map((sessionUser) => [ + sessionUser.id, + sessionUser.username, + ]), + ); + + const enrichedSessions = sessionList.map((session) => ({ + id: session.id, + userId: session.userId, + username: usernamesById.get(session.userId) || "Unknown", + deviceType: session.deviceType, + deviceInfo: session.deviceInfo, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + lastActiveAt: session.lastActiveAt, + isRevoked: session.isRevoked, + isCurrentSession: session.id === currentSessionId, + })); return res.json({ sessions: enrichedSessions }); } else { @@ -133,25 +133,19 @@ export function registerUserSessionRoutes( } try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; + const session = + await createCurrentSessionRepository().findById(sessionId); - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)) - .limit(1); - - if (sessionRecords.length === 0) { + if (!session) { return res.status(404).json({ error: "Session not found" }); } - const session = sessionRecords[0]; - if (!userRecord.isAdmin && session.userId !== userId) { return res .status(403) @@ -169,14 +163,9 @@ export function registerUserSessionRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const actorUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorUser[0]?.username ?? userId, + username: userRecord.username ?? userId, action: "revoke_session", resourceType: "session", resourceId: sessionId, @@ -230,13 +219,12 @@ export function registerUserSessionRoutes( const { targetUserId, exceptCurrent } = req.body; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - let revokeUserId = userId; if (targetUserId && userRecord.isAdmin) { revokeUserId = targetUserId; @@ -265,23 +253,17 @@ export function registerUserSessionRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const actorUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - const targetUserRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, revokeUserId)) - .limit(1); + const targetUserRecord = + revokeUserId === userId + ? userRecord + : await userRepository.findById(revokeUserId); await logAudit({ userId, - username: actorUser[0]?.username ?? userId, + username: userRecord.username ?? userId, action: "revoke_all_sessions", resourceType: "session", resourceId: revokeUserId, - resourceName: targetUserRecord[0]?.username, + resourceName: targetUserRecord?.username, details: JSON.stringify({ revokedCount, exceptCurrent }), ipAddress, userAgent, diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 047eda3b..2c23c107 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -1,19 +1,21 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; -import { restartGuacServer } from "../../guacamole/guacamole-server.js"; +import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js"; import { authLogger, getGlobalLogLevel, setGlobalLogLevel, } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { - formatGuacdOptions, - resolveGuacdOptions, -} from "../../utils/guacd-config.js"; + createCurrentSettingsRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import type { UserRecord } from "../repositories/user-repository.js"; + +function getDefaultGuacUrl(): string { + return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`; +} export type HostDefaults = { useSocks5?: boolean; @@ -33,6 +35,14 @@ export type HostDefaults = { enableCommandHistory?: boolean; }; +async function getAdminActor( + userId: string | undefined, +): Promise { + if (!userId) return null; + const user = await createCurrentUserRepository().findById(userId); + return user?.isAdmin ? user : null; +} + export function registerUserSettingsRoutes( router: Router, authenticateJWT: RequestHandler, @@ -62,15 +72,12 @@ export function registerUserSettingsRoutes( */ router.get("/guacamole-settings", authenticateJWT, async (_req, res) => { try { - const enabledRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get() as { value: string } | undefined; - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; + const settings = createCurrentSettingsRepository(); + const enabled = await settings.getBoolean("guac_enabled", true); + const url = await settings.get("guac_url"); res.json({ - enabled: enabledRow ? enabledRow.value !== "false" : true, - url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)), + enabled, + url: url ?? getDefaultGuacUrl(), }); } catch (err) { authLogger.error("Failed to get guacamole settings", err); @@ -108,24 +115,17 @@ export function registerUserSettingsRoutes( router.patch("/guacamole-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled, url } = req.body; + const settings = createCurrentSettingsRepository(); if (typeof enabled === "boolean") { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)", - ) - .run(enabled ? "true" : "false"); + await settings.set("guac_enabled", enabled ? "true" : "false"); } if (typeof url === "string") { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)", - ) - .run(url); + await settings.set("guac_url", url); try { await restartGuacServer(); } catch (err) { @@ -135,22 +135,13 @@ export function registerUserSettingsRoutes( ); } } - const enabledRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get() as { value: string } | undefined; - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; + const currentEnabled = await settings.getBoolean("guac_enabled", true); + const currentUrl = await settings.get("guac_url"); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_guacamole_settings", resourceType: "setting", details: JSON.stringify({ enabled, url }), @@ -160,8 +151,8 @@ export function registerUserSettingsRoutes( }); res.json({ - enabled: enabledRow ? enabledRow.value !== "false" : true, - url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)), + enabled: currentEnabled, + url: currentUrl ?? getDefaultGuacUrl(), }); } catch (err) { authLogger.error("Failed to update guacamole settings", err); @@ -183,11 +174,9 @@ export function registerUserSettingsRoutes( */ router.get("/log-level", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'log_level'") - .get() as { value: string } | undefined; + const level = await createCurrentSettingsRepository().get("log_level"); res.json({ - level: row ? row.value : getGlobalLogLevel(), + level: level ?? getGlobalLogLevel(), }); } catch (err) { authLogger.error("Failed to get log level", err); @@ -214,8 +203,8 @@ export function registerUserSettingsRoutes( router.patch("/log-level", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { level } = req.body; @@ -225,22 +214,13 @@ export function registerUserSettingsRoutes( .status(400) .json({ error: "level must be one of: debug, info, warn, error" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('log_level', ?)", - ) - .run(level); + await createCurrentSettingsRepository().set("log_level", level); setGlobalLogLevel(level); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_log_level", resourceType: "setting", details: JSON.stringify({ level }), @@ -270,13 +250,11 @@ export function registerUserSettingsRoutes( */ router.get("/session-timeout", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'session_timeout_hours'", - ) - .get() as { value: string } | undefined; + const value = await createCurrentSettingsRepository().get( + "session_timeout_hours", + ); res.json({ - timeoutHours: row ? parseInt(row.value, 10) : 24, + timeoutHours: value ? parseInt(value, 10) : 24, }); } catch (err) { authLogger.error("Failed to get session timeout", err); @@ -303,8 +281,8 @@ export function registerUserSettingsRoutes( router.patch("/session-timeout", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { timeoutHours } = req.body; @@ -317,21 +295,15 @@ export function registerUserSettingsRoutes( .status(400) .json({ error: "timeoutHours must be between 1 and 720" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)", - ) - .run(String(timeoutHours)); + await createCurrentSettingsRepository().set( + "session_timeout_hours", + String(timeoutHours), + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_session_timeout", resourceType: "setting", details: JSON.stringify({ timeoutHours }), @@ -371,10 +343,9 @@ export function registerUserSettingsRoutes( */ router.get("/tailscale-settings", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'") - .get() as { value: string } | undefined; - const apiKey = row?.value ?? ""; + const apiKey = + (await createCurrentSettingsRepository().get("tailscale_api_key")) ?? + ""; res.json({ apiKey: apiKey ? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}` @@ -415,29 +386,20 @@ export function registerUserSettingsRoutes( router.patch("/tailscale-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { apiKey } = req.body; if (typeof apiKey !== "string") { return res.status(400).json({ error: "apiKey must be a string" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('tailscale_api_key', ?)", - ) - .run(apiKey); + await createCurrentSettingsRepository().set("tailscale_api_key", apiKey); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_tailscale_settings", resourceType: "setting", details: JSON.stringify({ hasApiKey: !!apiKey }), @@ -474,12 +436,12 @@ export function registerUserSettingsRoutes( */ router.get("/command-history-enabled", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'command_history_enabled'", - ) - .get() as { value: string } | undefined; - res.json({ enabled: row ? row.value !== "false" : true }); + res.json({ + enabled: await createCurrentSettingsRepository().getBoolean( + "command_history_enabled", + true, + ), + }); } catch (err) { authLogger.error("Failed to get command history enabled setting", err); res @@ -519,29 +481,23 @@ export function registerUserSettingsRoutes( async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('command_history_enabled', ?)", - ) - .run(enabled ? "true" : "false"); + await createCurrentSettingsRepository().set( + "command_history_enabled", + enabled ? "true" : "false", + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_command_history_enabled", resourceType: "setting", details: JSON.stringify({ enabled }), @@ -579,10 +535,9 @@ export function registerUserSettingsRoutes( */ router.get("/host-defaults", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'host_defaults'") - .get() as { value: string } | undefined; - const defaults: HostDefaults = row ? JSON.parse(row.value) : {}; + const value = + await createCurrentSettingsRepository().get("host_defaults"); + const defaults: HostDefaults = value ? JSON.parse(value) : {}; res.json(defaults); } catch (err) { authLogger.error("Failed to get host defaults", err); @@ -615,26 +570,20 @@ export function registerUserSettingsRoutes( router.patch("/host-defaults", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const defaults: HostDefaults = req.body; - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('host_defaults', ?)", - ) - .run(JSON.stringify(defaults)); + await createCurrentSettingsRepository().set( + "host_defaults", + JSON.stringify(defaults), + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_host_defaults", resourceType: "setting", details: JSON.stringify(defaults), diff --git a/src/backend/database/routes/user-totp-routes.ts b/src/backend/database/routes/user-totp-routes.ts index 07b78efd..d5df38e0 100644 --- a/src/backend/database/routes/user-totp-routes.ts +++ b/src/backend/database/routes/user-totp-routes.ts @@ -1,10 +1,10 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Router } from "express"; -import { and, eq, ne } from "drizzle-orm"; import bcrypt from "bcryptjs"; import QRCode from "qrcode"; import speakeasy from "speakeasy"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { FieldCrypto } from "../../utils/field-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { authLogger } from "../../utils/logger.js"; @@ -13,8 +13,13 @@ import { generateDeviceFingerprint, parseUserAgent, } from "../../utils/user-agent-parser.js"; -import { db } from "../db/index.js"; -import { sessions, trustedDevices, users } from "../db/schema.js"; +import { + createCurrentSessionRepository, + createCurrentSettingsRepository, + createCurrentTrustedDeviceRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import type { UserRecord } from "../repositories/user-repository.js"; type NativeAppRequestChecker = (req: Request) => boolean; @@ -24,23 +29,11 @@ interface UserTotpRoutesDeps { isNativeAppRequest: NativeAppRequestChecker; } -type TotpUserRecord = typeof users.$inferSelect; - export async function verifyTotpReauth( - userRecord: TotpUserRecord, + userRecord: UserRecord, credential: string, userDataKey?: Buffer | null, ): Promise { - if (!userRecord.isOidc && userRecord.passwordHash) { - const passwordMatch = await bcrypt.compare( - credential, - userRecord.passwordHash, - ); - if (passwordMatch) { - return true; - } - } - if (userRecord.totpSecret) { const totpSecret = userDataKey ? LazyFieldEncryption.safeGetFieldValue( @@ -93,10 +86,9 @@ export async function verifyTotpReauth( "totpBackupCodes", ) : updatedJson; - await db - .update(users) - .set({ totpBackupCodes: storedValue }) - .where(eq(users.id, userRecord.id)); + await createCurrentUserRepository().update(userRecord.id, { + totpBackupCodes: storedValue, + }); return true; } } @@ -130,13 +122,11 @@ export function registerUserTotpRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } @@ -146,10 +136,9 @@ export function registerUserTotpRoutes( length: 32, }); - await db - .update(users) - .set({ totpSecret: secret.base32 }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpSecret: secret.base32, + }); const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ""); @@ -202,14 +191,11 @@ export function registerUserTotpRoutes( } try { - const passwordLoginRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_password_login'", - ) - .get() as { value: string } | undefined; - const passwordLoginAllowed = passwordLoginRow - ? passwordLoginRow.value === "true" - : true; + const passwordLoginAllowed = + await createCurrentSettingsRepository().getBoolean( + "allow_password_login", + true, + ); if (!passwordLoginAllowed) { return res.status(409).json({ error: @@ -217,13 +203,11 @@ export function registerUserTotpRoutes( }); } - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } @@ -267,26 +251,19 @@ export function registerUserTotpRoutes( ) : backupCodesJson; - await db - .update(users) - .set({ - totpEnabled: true, - totpBackupCodes: storedBackupCodes, - }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpEnabled: true, + totpBackupCodes: storedBackupCodes, + }); - await db - .delete(sessions) - .where( - sessionId - ? and(eq(sessions.userId, userId), ne(sessions.id, sessionId)) - : eq(sessions.userId, userId), - ); - await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId)); + await createCurrentSessionRepository().revokeAllForUser( + userId, + sessionId, + ); + await createCurrentTrustedDeviceRepository().deleteByUserId(userId); try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("totp_enable_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist TOTP enablement to disk", @@ -342,21 +319,27 @@ export function registerUserTotpRoutes( router.post("/totp/disable", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password, totp_code } = req.body; - const credential = password || totp_code; - - if (!credential) { - return res - .status(400) - .json({ error: "A TOTP code or password is required" }); - } - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; + if (!totp_code || (!userRecord.isOidc && !password)) { + return res.status(400).json({ + error: userRecord.isOidc + ? "A TOTP code is required" + : "Both password and TOTP code are required", + }); + } + + if ( + !userRecord.isOidc && + (!userRecord.passwordHash || + !(await bcrypt.compare(password, userRecord.passwordHash))) + ) { + return res.status(401).json({ error: "Incorrect password" }); + } if (!userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is not enabled" }); @@ -365,7 +348,7 @@ export function registerUserTotpRoutes( const userDataKey = authManager.getUserDataKey(userId); const verified = await verifyTotpReauth( userRecord, - credential, + totp_code, userDataKey, ); if (!verified) { @@ -374,14 +357,11 @@ export function registerUserTotpRoutes( .json({ error: "Incorrect password or invalid TOTP code" }); } - await db - .update(users) - .set({ - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); authLogger.info("Two-factor authentication disabled", { operation: "totp_disable", userId, @@ -428,21 +408,27 @@ export function registerUserTotpRoutes( router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password, totp_code } = req.body; - const credential = password || totp_code; - - if (!credential) { - return res - .status(400) - .json({ error: "A TOTP code or password is required" }); - } - try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; + if (!totp_code || (!userRecord.isOidc && !password)) { + return res.status(400).json({ + error: userRecord.isOidc + ? "A TOTP code is required" + : "Both password and TOTP code are required", + }); + } + + if ( + !userRecord.isOidc && + (!userRecord.passwordHash || + !(await bcrypt.compare(password, userRecord.passwordHash))) + ) { + return res.status(401).json({ error: "Incorrect password" }); + } if (!userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is not enabled" }); @@ -451,7 +437,7 @@ export function registerUserTotpRoutes( const userDataKey = authManager.getUserDataKey(userId); const verified = await verifyTotpReauth( userRecord, - credential, + totp_code, userDataKey, ); if (!verified) { @@ -474,10 +460,9 @@ export function registerUserTotpRoutes( ) : backupCodesJson; - await db - .update(users) - .set({ totpBackupCodes: storedBackupCodes }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpBackupCodes: storedBackupCodes, + }); res.json({ backup_codes: backupCodes }); } catch (err) { @@ -532,16 +517,13 @@ export function registerUserTotpRoutes( return res.status(401).json({ error: "Invalid temporary token" }); } - const user = await db - .select() - .from(users) - .where(eq(users.id, decoded.userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById( + decoded.userId, + ); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id); if (lockStatus.locked) { authLogger.warn("TOTP verification blocked due to rate limiting", { @@ -580,14 +562,11 @@ export function registerUserTotpRoutes( ); if (!totpSecret) { - await db - .update(users) - .set({ - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }) - .where(eq(users.id, userRecord.id)); + await createCurrentUserRepository().update(userRecord.id, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); return res.status(400).json({ error: @@ -635,10 +614,9 @@ export function registerUserTotpRoutes( } backupCodes.splice(backupIndex, 1); - await db - .update(users) - .set({ totpBackupCodes: JSON.stringify(backupCodes) }) - .where(eq(users.id, userRecord.id)); + await createCurrentUserRepository().update(userRecord.id, { + totpBackupCodes: JSON.stringify(backupCodes), + }); } loginRateLimiter.resetTOTPAttempts(userRecord.id); @@ -683,14 +661,10 @@ export function registerUserTotpRoutes( ...(isNativeAppRequest(req) ? { token } : {}), }; - const timeoutRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'session_timeout_hours'", - ) - .get() as { value: string } | undefined; - const timeoutHours = timeoutRow - ? parseInt(timeoutRow.value, 10) || 24 - : 24; + const timeoutValue = await createCurrentSettingsRepository().get( + "session_timeout_hours", + ); + const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24; const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : timeoutHours * 60 * 60 * 1000; diff --git a/src/backend/database/routes/user-webauthn-routes.ts b/src/backend/database/routes/user-webauthn-routes.ts index 6eff114b..2d247dcd 100644 --- a/src/backend/database/routes/user-webauthn-routes.ts +++ b/src/backend/database/routes/user-webauthn-routes.ts @@ -12,7 +12,6 @@ import { verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server"; -import { and, eq } from "drizzle-orm"; import { nanoid } from "nanoid"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -21,8 +20,12 @@ import { generateDeviceFingerprint, parseUserAgent, } from "../../utils/user-agent-parser.js"; -import { db, saveMemoryDatabaseToFile } from "../db/index.js"; -import { users, webauthnCredentials } from "../db/schema.js"; +import { + createCurrentUserRepository, + createCurrentWebauthnCredentialRepository, + getCurrentSettingValue, +} from "../repositories/factory.js"; +import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js"; type UserVerification = "discouraged" | "preferred" | "required"; type NativeAppRequestChecker = (req: Request) => boolean; @@ -113,7 +116,7 @@ function parseTransports(value: string | null): AuthenticatorTransportFuture[] { } function getCredentialForVerification( - credential: typeof webauthnCredentials.$inferSelect, + credential: WebauthnCredentialRecord, ): WebAuthnCredential { return { id: credential.credentialId as Base64URLString, @@ -129,16 +132,28 @@ export function registerUserWebAuthnRoutes( router: Router, { authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps, ): void { + /** + * @openapi + * /users/webauthn/credentials: + * get: + * summary: List passkeys + * description: Lists the authenticated user's registered passkeys. + * tags: + * - WebAuthn + * responses: + * 200: + * description: List of passkeys. + * 401: + * description: Authentication required. + */ router.get("/webauthn/credentials", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } - const credentials = await db - .select() - .from(webauthnCredentials) - .where(eq(webauthnCredentials.userId, userId)); + const credentials = + await createCurrentWebauthnCredentialRepository().listByUserId(userId); res.json({ credentials: credentials.map((credential) => ({ @@ -154,6 +169,22 @@ export function registerUserWebAuthnRoutes( }); }); + /** + * @openapi + * /users/webauthn/register/options: + * post: + * summary: Start passkey registration + * description: Generates WebAuthn registration options for the authenticated user. + * tags: + * - WebAuthn + * responses: + * 200: + * description: Registration options and challenge id. + * 401: + * description: Authentication required. + * 404: + * description: User not found. + */ router.post( "/webauthn/register/options", authenticateJWT, @@ -169,15 +200,13 @@ export function registerUserWebAuthnRoutes( }); } - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user.length) { + const user = await createCurrentUserRepository().findById(userId); + if (!user) { return res.status(404).json({ error: "User not found" }); } - const existing = await db - .select() - .from(webauthnCredentials) - .where(eq(webauthnCredentials.userId, userId)); + const existing = + await createCurrentWebauthnCredentialRepository().listByUserId(userId); const origin = getRequestOrigin(req); const rpID = getRpID(origin); @@ -189,8 +218,8 @@ export function registerUserWebAuthnRoutes( rpName: "Termix", rpID, userID: Buffer.from(userId, "utf8"), - userName: user[0].username, - userDisplayName: user[0].username, + userName: user.username, + userDisplayName: user.username, attestationType: "none", excludeCredentials: existing.map((credential) => ({ id: credential.credentialId as Base64URLString, @@ -214,6 +243,22 @@ export function registerUserWebAuthnRoutes( }, ); + /** + * @openapi + * /users/webauthn/register/verify: + * post: + * summary: Finish passkey registration + * description: Verifies the WebAuthn registration response and stores the passkey. + * tags: + * - WebAuthn + * responses: + * 200: + * description: Passkey registered. + * 400: + * description: Registration failed or challenge expired. + * 401: + * description: Authentication required. + */ router.post( "/webauthn/register/verify", authenticateJWT, @@ -252,14 +297,12 @@ export function registerUserWebAuthnRoutes( (req.body?.response as RegistrationResponseJSON | undefined)?.response ?.transports ?? []; - await authManager.setupWebAuthnUserEncryption(userId); - const name = typeof req.body?.name === "string" && req.body.name.trim() ? req.body.name.trim().slice(0, 80) : "Passkey"; - await db.insert(webauthnCredentials).values({ + await createCurrentWebauthnCredentialRepository().create({ id: nanoid(), userId, name, @@ -273,7 +316,6 @@ export function registerUserWebAuthnRoutes( createdAt: new Date().toISOString(), }); - await saveMemoryDatabaseToFile(); res.json({ success: true }); } catch (error) { authLogger.warn("WebAuthn registration failed", { @@ -286,6 +328,20 @@ export function registerUserWebAuthnRoutes( }, ); + /** + * @openapi + * /users/webauthn/authenticate/options: + * post: + * summary: Start passkey login + * description: Generates WebAuthn authentication options, optionally scoped to a username. + * tags: + * - WebAuthn + * responses: + * 200: + * description: Authentication options and challenge id. + * 404: + * description: No passkeys found for the user. + */ router.post("/webauthn/authenticate/options", async (req, res) => { const origin = getRequestOrigin(req); const rpID = getRpID(origin); @@ -301,19 +357,14 @@ export function registerUserWebAuthnRoutes( | undefined; if (username) { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (!user.length) { + const user = await createCurrentUserRepository().findByUsername(username); + if (!user) { return res.status(404).json({ error: "No passkeys found" }); } - userId = user[0].id; - const credentials = await db - .select() - .from(webauthnCredentials) - .where(eq(webauthnCredentials.userId, userId)); + userId = user.id; + const credentials = + await createCurrentWebauthnCredentialRepository().listByUserId(userId); if (!credentials.length) { return res.status(404).json({ error: "No passkeys found" }); @@ -342,6 +393,22 @@ export function registerUserWebAuthnRoutes( res.json({ options, challengeId }); }); + /** + * @openapi + * /users/webauthn/authenticate/verify: + * post: + * summary: Finish passkey login + * description: Verifies the WebAuthn assertion and issues a session token (or a TOTP challenge). + * tags: + * - WebAuthn + * responses: + * 200: + * description: Login succeeded or TOTP verification required. + * 400: + * description: Challenge expired or invalid response. + * 401: + * description: Passkey not recognized or authentication failed. + */ router.post("/webauthn/authenticate/verify", async (req, res) => { const challenge = takeChallenge( authenticationChallenges, @@ -360,16 +427,15 @@ export function registerUserWebAuthnRoutes( return res.status(400).json({ error: "Invalid passkey response" }); } - const credentials = await db - .select() - .from(webauthnCredentials) - .where(eq(webauthnCredentials.credentialId, response.id)); + const credential = + await createCurrentWebauthnCredentialRepository().findByCredentialId( + response.id, + ); - if (!credentials.length) { + if (!credential) { return res.status(401).json({ error: "Passkey not recognized" }); } - const credential = credentials[0]; if (challenge.userId && challenge.userId !== credential.userId) { return res.status(401).json({ error: "Passkey not recognized" }); } @@ -391,37 +457,35 @@ export function registerUserWebAuthnRoutes( return res.status(401).json({ error: "Passkey authentication failed" }); } - const user = await db - .select() - .from(users) - .where(eq(users.id, credential.userId)); - if (!user.length) { + const userRecord = await createCurrentUserRepository().findById( + credential.userId, + ); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; const deviceInfo = parseUserAgent(req); - const dataUnlocked = await authManager.authenticateWebAuthnUser( + const authenticated = await authManager.authenticateWebAuthnUser( userRecord.id, deviceInfo.type, ); - if (!dataUnlocked) { + if (!authenticated) { return res.status(401).json({ error: "Passkey cannot unlock this account. Log in with password and register the passkey again.", }); } - await db - .update(webauthnCredentials) - .set({ + await createCurrentWebauthnCredentialRepository().updateAuthState( + credential.id, + { counter: verification.authenticationInfo.newCounter, backedUp: verification.authenticationInfo.credentialBackedUp, deviceType: verification.authenticationInfo.credentialDeviceType, lastUsedAt: new Date().toISOString(), - }) - .where(eq(webauthnCredentials.id, credential.id)); + }, + ); if (userRecord.totpEnabled) { const deviceFingerprint = generateDeviceFingerprint(deviceInfo); @@ -431,7 +495,6 @@ export function registerUserWebAuthnRoutes( ); if (!isTrusted) { - await saveMemoryDatabaseToFile(); const tempToken = await authManager.generateJWTToken(userRecord.id, { pendingTOTP: true, expiresIn: "10m", @@ -451,15 +514,9 @@ export function registerUserWebAuthnRoutes( deviceInfo: deviceInfo.deviceInfo, }); - await saveMemoryDatabaseToFile(); - - const timeoutRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'session_timeout_hours'", - ) - .get() as { value: string } | undefined; - const timeoutHours = timeoutRow - ? parseInt(timeoutRow.value, 10) || 24 + const timeoutSetting = getCurrentSettingValue("session_timeout_hours"); + const timeoutHours = timeoutSetting + ? parseInt(timeoutSetting, 10) || 24 : 24; const maxAge = req.body?.rememberMe ? 30 * 24 * 60 * 60 * 1000 @@ -473,7 +530,6 @@ export function registerUserWebAuthnRoutes( userId: userRecord.id, is_oidc: !!userRecord.isOidc, totp_enabled: !!userRecord.totpEnabled, - data_unlocked: true, ...(isNativeAppRequest(req) ? { token } : {}), }); } catch (error) { @@ -487,6 +543,25 @@ export function registerUserWebAuthnRoutes( } }); + /** + * @openapi + * /users/webauthn/credentials/{credentialId}: + * delete: + * summary: Delete a passkey + * description: Removes one of the authenticated user's passkeys. + * tags: + * - WebAuthn + * parameters: + * - in: path + * name: credentialId + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Passkey deleted. + * 401: + * description: Authentication required. + */ router.delete( "/webauthn/credentials/:credentialId", authenticateJWT, @@ -498,15 +573,10 @@ export function registerUserWebAuthnRoutes( const credentialId = String(req.params.credentialId); - await db - .delete(webauthnCredentials) - .where( - and( - eq(webauthnCredentials.id, credentialId), - eq(webauthnCredentials.userId, userId), - ), - ); - await saveMemoryDatabaseToFile(); + await createCurrentWebauthnCredentialRepository().deleteForUser( + userId, + credentialId, + ); res.json({ success: true }); }, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 1f18b525..f58f34b8 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1,24 +1,24 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { users, settings, roles, userRoles } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseUserAgent, generateDeviceFingerprint, } from "../../utils/user-agent-parser.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; +import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js"; import { - getRequestBasePath, - getRequestBaseUrlWithForceHTTPS, -} from "../../utils/request-origin.js"; + getDesktopOidcCallbackUrl, + isOidcTokenCallback, +} from "../../utils/oidc-desktop-callback.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; +import { shouldShowDonationModal } from "./donation-modal-utils.js"; import { getOIDCConfigFromEnv, isOIDCUserAllowed, @@ -26,20 +26,29 @@ import { extractOidcGroups, loadProviderConfig, buildFetchOptions, + resolveProviderByIssuer, + validateLogoutToken, } from "./user-oidc-utils.js"; import { registerUserApiKeyRoutes } from "./user-api-key-routes.js"; import { registerUserSettingsRoutes } from "./user-settings-routes.js"; import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js"; import { registerUserTotpRoutes } from "./user-totp-routes.js"; +import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; -import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js"; import { registerSSOProviderRoutes } from "./sso-provider-routes.js"; import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentSettingsRepository, + getCurrentSettingValue, + createCurrentRoleRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import type { UserRecord } from "../repositories/user-repository.js"; const authManager = AuthManager.getInstance(); @@ -50,13 +59,11 @@ async function syncSharedCredentialsForUserRoles( operation: string, ) { try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.createSharedCredentialsForUserRoles(userId); - await sharedCredManager.reEncryptPendingCredentialsForUser(userId); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId); } catch (error) { - authLogger.warn("Failed to sync role shared credentials", { + authLogger.warn("Failed to sync role shared host secrets", { operation, userId, error, @@ -72,10 +79,8 @@ function isRegistrationAllowed(): boolean { const envVal = process.env.ALLOW_REGISTRATION; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") - .get() as { value: string } | undefined; - return row ? row.value === "true" : true; + const value = getCurrentSettingValue("allow_registration"); + return value ? value === "true" : true; } catch { return true; } @@ -85,10 +90,8 @@ function isPasswordLoginAllowed(): boolean { const envVal = process.env.ALLOW_PASSWORD_LOGIN; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") - .get() as { value: string } | undefined; - return row ? row.value === "true" : true; + const value = getCurrentSettingValue("allow_password_login"); + return value ? value === "true" : true; } catch { return true; } @@ -98,10 +101,8 @@ function isPasswordResetAllowed(): boolean { const envVal = process.env.ALLOW_PASSWORD_RESET; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") - .get() as { value: string } | undefined; - return row ? row.value === "true" : true; + const value = getCurrentSettingValue("allow_password_reset"); + return value ? value === "true" : true; } catch { return true; } @@ -114,6 +115,24 @@ function isNativeAppRequest(req: Request): boolean { ); } +async function findCurrentUser(userId: string): Promise { + return createCurrentUserRepository().findById(userId); +} + +async function requireCurrentAdmin(userId: string): Promise { + const user = await findCurrentUser(userId); + return user?.isAdmin ? user : null; +} + +async function deleteOIDCStateSettings(state: string): Promise { + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.delete(`oidc_state_${state}`); + await settingsRepository.delete(`oidc_backend_callback_${state}`); + await settingsRepository.delete(`oidc_frontend_origin_${state}`); + await settingsRepository.delete(`oidc_remember_me_${state}`); + await settingsRepository.delete(`oidc_provider_${state}`); +} + const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); @@ -176,11 +195,9 @@ router.post("/create", async (req, res) => { } try { - const existing = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (existing && existing.length > 0) { + const userRepository = createCurrentUserRepository(); + const existing = await userRepository.findByUsername(username); + if (existing) { authLogger.warn("Registration failed - username exists", { operation: "user_register_failed", username, @@ -192,51 +209,35 @@ router.post("/create", async (req, res) => { const password_hash = await bcrypt.hash(password, 10); const id = nanoid(); - const isFirstUser = db.$client.transaction(() => { - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const first = (countResult?.count || 0) === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - username, - password_hash, - first ? 1 : 0, - 0, - "", - "", - "", - "", - "", - "", - "", - "openid email profile", - null, - 0, - null, - ); - return first; - })(); + const { isFirstUser } = await userRepository.createFirstLocalUser({ + id, + username, + passwordHash: password_hash, + isOidc: false, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); try { const defaultRoleName = isFirstUser ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - - if (defaultRole.length > 0) { - await db.insert(userRoles).values({ + const assigned = await createCurrentRoleRepository().assignRoleNameToUser( + { userId: id, - roleId: defaultRole[0].id, + roleName: defaultRoleName, grantedBy: id, - }); - } else { + }, + ); + + if (!assigned) { authLogger.warn("Default role not found during user registration", { operation: "assign_default_role", userId: id, @@ -253,7 +254,7 @@ router.post("/create", async (req, res) => { try { await authManager.registerUser(id, password); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, id)); + await userRepository.delete(id); authLogger.error( "Failed to setup user encryption, user creation rolled back", encryptionError, @@ -268,8 +269,7 @@ router.post("/create", async (req, res) => { } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("user_create_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist user to disk", saveError, { operation: "user_create_save_failed", @@ -327,8 +327,8 @@ router.post("/create", async (req, res) => { router.post("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } @@ -382,10 +382,10 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { .json({ error: "All OIDC configuration fields are required" }); } + const settingsRepository = createCurrentSettingsRepository(); + if (isDisableRequest) { - db.$client - .prepare("DELETE FROM settings WHERE key = 'oidc_config'") - .run(); + await settingsRepository.delete("oidc_config"); authLogger.info("OIDC configuration disabled", { operation: "oidc_disable", userId, @@ -446,11 +446,10 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { }; } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)", - ) - .run(JSON.stringify(encryptedConfig)); + await settingsRepository.set( + "oidc_config", + JSON.stringify(encryptedConfig), + ); authLogger.info("OIDC configuration updated", { operation: "oidc_update", userId, @@ -483,12 +482,12 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { router.delete("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } - db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); + await createCurrentSettingsRepository().delete("oidc_config"); authLogger.success("OIDC configuration disabled", { operation: "oidc_disable", userId, @@ -550,15 +549,13 @@ router.get("/oidc-config", async (_req, res) => { router.get("/oidc-config/admin", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") - .get(); - if (!row) { + const value = await createCurrentSettingsRepository().get("oidc_config"); + if (!value) { const envConfig = getOIDCConfigFromEnv(); return res.json(envConfig); } - let config = JSON.parse((row as Record).value as string); + let config = JSON.parse(value); if (config.client_secret?.startsWith("encrypted:")) { try { @@ -633,8 +630,9 @@ router.get("/oidc/authorize", async (req, res) => { appCallbackUrl, providerId: providerIdStr, } = req.query; - const publicBaseUrl = getRequestBaseUrlWithForceHTTPS(req); - const backendCallbackUri = `${publicBaseUrl}/users/oidc/callback`; + const origin = getRequestOriginWithForceHTTPS(req); + const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, ""); + const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`; const resolvedProviderId = providerIdStr ? parseInt(providerIdStr as string, 10) @@ -652,7 +650,10 @@ router.get("/oidc/authorize", async (req, res) => { const referer = req.get("Referer"); let frontendOrigin; if (desktopCallbackPort) { - frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`; + frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort); + if (!frontendOrigin) { + return res.status(400).json({ error: "Invalid desktop callback port" }); + } } else if (typeof appCallbackUrl === "string" && appCallbackUrl) { let callbackUrl: URL; try { @@ -666,34 +667,31 @@ router.get("/oidc/authorize", async (req, res) => { frontendOrigin = callbackUrl.toString(); } else if (referer) { const refererUrl = new URL(referer); - frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}${getRequestBasePath(req)}`; + frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; } else { - frontendOrigin = publicBaseUrl; + frontendOrigin = origin; } - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_state_${state}`, nonce); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_backend_callback_${state}`, backendCallbackUri); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_frontend_origin_${state}`, frontendOrigin); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run( - `oidc_remember_me_${state}`, - rememberMe === "true" ? "true" : "false", - ); + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.set(`oidc_state_${state}`, nonce); + await settingsRepository.set( + `oidc_backend_callback_${state}`, + backendCallbackUri, + ); + await settingsRepository.set( + `oidc_frontend_origin_${state}`, + frontendOrigin, + ); + await settingsRepository.set( + `oidc_remember_me_${state}`, + rememberMe === "true" ? "true" : "false", + ); if (providerDbId != null) { - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_provider_${state}`, String(providerDbId)); + await settingsRepository.set( + `oidc_provider_${state}`, + String(providerDbId), + ); } const authUrl = new URL(config.authorization_url); @@ -732,43 +730,38 @@ router.get("/oidc/callback", async (req, res) => { return res.status(400).json({ error: "Code and state are required" }); } - const storedBackendCallbackRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_backend_callback_${state}`); - const storedFrontendOriginRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_frontend_origin_${state}`); - const storedRememberMeRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_remember_me_${state}`); + const settingsRepository = createCurrentSettingsRepository(); + const storedBackendCallback = await settingsRepository.get( + `oidc_backend_callback_${state}`, + ); + const storedFrontendOrigin = await settingsRepository.get( + `oidc_frontend_origin_${state}`, + ); + const storedRememberMeValue = await settingsRepository.get( + `oidc_remember_me_${state}`, + ); - if (!storedBackendCallbackRow || !storedFrontendOriginRow) { + if (!storedBackendCallback || !storedFrontendOrigin) { return res .status(400) .json({ error: "Invalid state parameter - redirect URIs not found" }); } - const backendCallbackUri = ( - storedBackendCallbackRow as Record - ).value as string; - const frontendOrigin = (storedFrontendOriginRow as Record) - .value as string; - const storedRememberMe = - (storedRememberMeRow as Record | null)?.value === "true"; + const backendCallbackUri = storedBackendCallback; + const frontendOrigin = storedFrontendOrigin; + const storedRememberMe = storedRememberMeValue === "true"; try { - const storedNonce = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_state_${state}`); + const storedNonce = await settingsRepository.get(`oidc_state_${state}`); if (!storedNonce) { return res.status(400).json({ error: "Invalid state parameter" }); } - const storedProviderIdRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_provider_${state}`) as { value: string } | null; - const callbackProviderId = storedProviderIdRow - ? parseInt(storedProviderIdRow.value, 10) + const storedProviderId = await settingsRepository.get( + `oidc_provider_${state}`, + ); + const callbackProviderId = storedProviderId + ? parseInt(storedProviderId, 10) : null; const providerResult = await loadProviderConfig( @@ -783,12 +776,7 @@ router.get("/oidc/callback", async (req, res) => { providerDbId: callbackProviderDbId, } = providerResult; - // Clean up provider state key - if (storedProviderIdRow) { - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_provider_${state}`); - } + await settingsRepository.delete(`oidc_provider_${state}`); const caCert = config.ca_cert; const fetchOptions = buildFetchOptions(caCert); @@ -827,18 +815,7 @@ router.get("/oidc/callback", async (req, res) => { string, unknown >; - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_state_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_backend_callback_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_frontend_origin_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_remember_me_${state}`); + await deleteOIDCStateSettings(state); const ghUserInfoResponse = await fetch("https://api.github.com/user", { headers: { @@ -880,16 +857,12 @@ router.get("/oidc/callback", async (req, res) => { ghUserInfo.login || ghIdentifier) as string; const deviceInfo = parseUserAgent(req); + const userRepository = createCurrentUserRepository(); - let ghUser = await db - .select() - .from(users) - .where(eq(users.oidcIdentifier, ghIdentifier)); - if (!ghUser || ghUser.length === 0) { - const preCheckCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const isFirstUser = (preCheckCount?.count || 0) === 0; + let ghUserRecord = + await userRepository.findByOidcIdentifier(ghIdentifier); + if (!ghUserRecord) { + const isFirstUser = (await userRepository.countAll()) === 0; if (!isFirstUser && config.allowed_users) { const email = ghUserInfo.email as string | undefined; @@ -902,12 +875,10 @@ router.get("/oidc/callback", async (req, res) => { let ghAutoProvision = false; try { - const r = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", - ) - .get() as { value: string } | undefined; - if (r) ghAutoProvision = r.value === "true"; + ghAutoProvision = await settingsRepository.getBoolean( + "oidc_auto_provision", + false, + ); } catch { /* */ } @@ -923,42 +894,23 @@ router.get("/oidc/callback", async (req, res) => { } const ghId = nanoid(); - const ghIsFirst = db.$client.transaction(() => { - const c = - ( - db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number } - )?.count || 0; - const first = c === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)", - ) - .run( - ghId, - ghName, - "", - first ? 1 : 0, - ghIdentifier, - callbackProviderDbId, - ); - return first; - })(); + const createdUser = await userRepository.createFirstLocalUser({ + id: ghId, + username: ghName, + passwordHash: "", + isOidc: true, + oidcIdentifier: ghIdentifier, + ssoProviderId: callbackProviderDbId, + }); + ghUserRecord = createdUser.user; try { - const defaultRoleName = ghIsFirst ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - if (defaultRole.length > 0) - await db.insert(userRoles).values({ - userId: ghId, - roleId: defaultRole[0].id, - grantedBy: ghId, - }); + const defaultRoleName = createdUser.isFirstUser ? "admin" : "user"; + await createCurrentRoleRepository().assignRoleNameToUser({ + userId: ghId, + roleName: defaultRoleName, + grantedBy: ghId, + }); } catch { /* */ } @@ -970,16 +922,13 @@ router.get("/oidc/callback", async (req, res) => { : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(ghId, sessionDurationMs); } catch { - await db.delete(users).where(eq(users.id, ghId)); + await userRepository.delete(ghId); return res.status(500).json({ error: "Failed to setup user security - user creation cancelled", }); } - - ghUser = await db.select().from(users).where(eq(users.id, ghId)); } - const ghUserRecord = ghUser[0]; try { await authManager.authenticateOIDCUser( ghUserRecord.id, @@ -999,9 +948,7 @@ router.get("/oidc/callback", async (req, res) => { }); const ghRedirectUrl = new URL(frontendOrigin); ghRedirectUrl.searchParams.set("success", "true"); - const ghIsTokenCallback = - frontendOrigin.startsWith("http://127.0.0.1:") || - frontendOrigin.startsWith("termix-mobile:"); + const ghIsTokenCallback = isOidcTokenCallback(frontendOrigin); const ghMaxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 @@ -1055,18 +1002,7 @@ router.get("/oidc/callback", async (req, res) => { const tokenData = (await tokenResponse.json()) as Record; - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_state_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_backend_callback_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_frontend_origin_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_remember_me_${state}`); + await deleteOIDCStateSettings(state); let userInfo: Record = null; const userInfoUrls: string[] = []; @@ -1108,25 +1044,20 @@ router.get("/oidc/callback", async (req, res) => { ); if (tokenData.id_token) { - try { - userInfo = await verifyOIDCToken( - tokenData.id_token as string, - config.issuer_url, - config.client_id, - caCert, - ); - } catch { - try { - const parts = (tokenData.id_token as string).split("."); - if (parts.length === 3) { - const payload = JSON.parse( - Buffer.from(parts[1], "base64").toString(), - ); - userInfo = payload; - } - } catch (decodeError) { - authLogger.error("Failed to decode ID token payload:", decodeError); - } + userInfo = await verifyOIDCToken( + tokenData.id_token as string, + config.issuer_url, + config.client_id, + caCert, + ); + + const expectedNonce = storedNonce; + if (userInfo.nonce !== expectedNonce) { + authLogger.warn("OIDC ID token nonce mismatch", { + operation: "oidc_nonce_mismatch", + providerId: callbackProviderId, + }); + return res.status(401).json({ error: "Invalid OIDC token nonce" }); } } @@ -1199,17 +1130,12 @@ router.get("/oidc/callback", async (req, res) => { } const deviceInfo = parseUserAgent(req); - let user = await db - .select() - .from(users) - .where(eq(users.oidcIdentifier, identifier)); + const userRepository = createCurrentUserRepository(); + let userRecord = await userRepository.findByOidcIdentifier(identifier); let isFirstUser = false; - if (!user || user.length === 0) { - const preCheckCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get(); - isFirstUser = ((preCheckCount as { count?: number })?.count || 0) === 0; + if (!userRecord) { + isFirstUser = (await userRepository.countAll()) === 0; if (!isFirstUser && config.allowed_users) { const email = userInfo.email as string | undefined; @@ -1227,15 +1153,10 @@ router.get("/oidc/callback", async (req, res) => { let oidcAutoProvision = false; try { - const oidcProvRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", - ) - .get(); - if (oidcProvRow) { - oidcAutoProvision = - (oidcProvRow as Record).value === "true"; - } + oidcAutoProvision = await settingsRepository.getBoolean( + "oidc_auto_provision", + false, + ); } catch { // fall through to env var check } @@ -1261,42 +1182,27 @@ router.get("/oidc/callback", async (req, res) => { } const id = nanoid(); - isFirstUser = db.$client.transaction(() => { - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const first = (countResult?.count || 0) === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - name, - "", - first ? 1 : 0, - 1, - identifier, - callbackProviderDbId, - ); - return first; - })(); + const createdUser = await userRepository.createFirstLocalUser({ + id, + username: name, + passwordHash: "", + isOidc: true, + oidcIdentifier: identifier, + ssoProviderId: callbackProviderDbId, + }); + isFirstUser = createdUser.isFirstUser; + userRecord = createdUser.user; try { const defaultRoleName = isFirstUser ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - - if (defaultRole.length > 0) { - await db.insert(userRoles).values({ + const assigned = + await createCurrentRoleRepository().assignRoleNameToUser({ userId: id, - roleId: defaultRole[0].id, + roleName: defaultRoleName, grantedBy: id, }); - } else { + + if (!assigned) { authLogger.warn( "Default role not found during OIDC user registration", { @@ -1324,7 +1230,7 @@ router.get("/oidc/callback", async (req, res) => { : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(id, sessionDurationMs); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, id)); + await userRepository.delete(id); authLogger.error( "Failed to setup OIDC user encryption, user creation rolled back", encryptionError, @@ -1339,16 +1245,13 @@ router.get("/oidc/callback", async (req, res) => { } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("oidc_user_create_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist OIDC user to disk", saveError, { operation: "oidc_user_create_save_failed", userId: id, }); } - - user = await db.select().from(users).where(eq(users.id, id)); } else { if (config.allowed_users) { const email = userInfo.email as string | undefined; @@ -1357,7 +1260,7 @@ router.get("/oidc/callback", async (req, res) => { operation: "oidc_user_not_allowed_existing", identifier, email, - userId: user[0].id, + userId: userRecord.id, }); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "user_not_allowed"); @@ -1366,20 +1269,15 @@ router.get("/oidc/callback", async (req, res) => { } const isDualAuth = - user[0].passwordHash && user[0].passwordHash.trim() !== ""; + userRecord.passwordHash && userRecord.passwordHash.trim() !== ""; if (!isDualAuth) { - await db - .update(users) - .set({ username: name }) - .where(eq(users.id, user[0].id)); + userRecord = + (await userRepository.update(userRecord.id, { username: name })) ?? + userRecord; } - - user = await db.select().from(users).where(eq(users.id, user[0].id)); } - const userRecord = user[0]; - // Sync admin status based on OIDC group membership if (config.admin_group) { const groups = extractOidcGroups( @@ -1403,41 +1301,19 @@ router.get("/oidc/callback", async (req, res) => { group: config.admin_group, isAdmin: shouldBeAdmin, }); - await db - .update(users) - .set({ isAdmin: shouldBeAdmin }) - .where(eq(users.id, userRecord.id)); - userRecord.isAdmin = shouldBeAdmin; + userRecord = + (await userRepository.update(userRecord.id, { + isAdmin: shouldBeAdmin, + })) ?? userRecord; try { const newRoleName = shouldBeAdmin ? "admin" : "user"; const oldRoleName = shouldBeAdmin ? "user" : "admin"; - const newRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, newRoleName)) - .limit(1); - const oldRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, oldRoleName)) - .limit(1); - if (oldRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, userRecord.id), - eq(userRoles.roleId, oldRole[0].id), - ), - ); - } - if (newRole.length > 0) { - await db.insert(userRoles).values({ - userId: userRecord.id, - roleId: newRole[0].id, - grantedBy: userRecord.id, - }); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId: userRecord.id, + addRoleName: newRoleName, + removeRoleName: oldRoleName, + grantedBy: userRecord.id, + }); } catch { /* non-fatal */ } @@ -1464,10 +1340,16 @@ router.get("/oidc/callback", async (req, res) => { "oidc_role_shared_credentials", ); + const oidcSub = typeof userInfo.sub === "string" ? userInfo.sub : null; + const oidcSid = typeof userInfo.sid === "string" ? userInfo.sid : null; + const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, rememberMe: storedRememberMe, + oidcSub, + oidcSid, + ssoProviderId: callbackProviderDbId ?? null, }); authLogger.success("OIDC login successful", { @@ -1479,9 +1361,7 @@ router.get("/oidc/callback", async (req, res) => { const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("success", "true"); - const isTokenCallback = - frontendOrigin.startsWith("http://127.0.0.1:") || - frontendOrigin.startsWith("termix-mobile:"); + const isTokenCallback = isOidcTokenCallback(frontendOrigin); const maxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" @@ -1581,12 +1461,10 @@ router.post("/login", async (req, res) => { } try { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); + const userRecord = + await createCurrentUserRepository().findByUsername(username); - if (!user || user.length === 0) { + if (!userRecord) { loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: user not found`, { operation: "user_login", @@ -1600,8 +1478,6 @@ router.post("/login", async (req, res) => { return res.status(401).json({ error: "Invalid username or password" }); } - const userRecord = user[0]; - if ( userRecord.isOidc && (!userRecord.passwordHash || userRecord.passwordHash.trim() === "") @@ -1632,36 +1508,23 @@ router.post("/login", async (req, res) => { return res.status(401).json({ error: "Invalid username or password" }); } - try { - const kekSalt = await db - .select() - .from(settings) - .where(eq(settings.key, `user_kek_salt_${userRecord.id}`)); - - if (kekSalt.length === 0) { - await authManager.registerUser(userRecord.id, password); - } - } catch { - // expected - KEK salt registration may fail for existing users - } - const deviceInfo = parseUserAgent(req); - let dataUnlocked = false; + let authenticated = false; if (userRecord.isOidc) { - dataUnlocked = await authManager.authenticateOIDCUser( + authenticated = await authManager.authenticateOIDCUser( userRecord.id, deviceInfo.type, ); } else { - dataUnlocked = await authManager.authenticateUser( + authenticated = await authManager.authenticateUser( userRecord.id, password, deviceInfo.type, ); } - if (!dataUnlocked) { + if (!authenticated) { return res.status(401).json({ error: "Incorrect password" }); } @@ -1732,10 +1595,11 @@ router.post("/login", async (req, res) => { ...(isNativeAppRequest(req) ? { token } : {}), }; - const timeoutRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'") - .get() as { value: string } | undefined; - const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24; + const sessionTimeoutHoursValue = + await createCurrentSettingsRepository().get("session_timeout_hours"); + const timeoutHours = sessionTimeoutHoursValue + ? parseInt(sessionTimeoutHoursValue, 10) || 24 + : 24; const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : timeoutHours * 60 * 60 * 1000; @@ -1788,6 +1652,84 @@ router.post("/logout", authenticateJWT, async (req, res) => { } }); +const seenLogoutJti = new Map(); +const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000; + +function pruneLogoutJti(now: number): void { + for (const [key, expiry] of seenLogoutJti) { + if (expiry <= now) seenLogoutJti.delete(key); + } +} + +function isReplayedJti(jti: string): boolean { + const now = Date.now(); + pruneLogoutJti(now); + return seenLogoutJti.has(jti); +} + +function markLogoutJti(jti: string): void { + const now = Date.now(); + pruneLogoutJti(now); + seenLogoutJti.set(jti, now + LOGOUT_JTI_TTL_MS); +} + +router.post("/oidc/backchannel-logout", async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + + try { + const logoutToken = (req.body as Record | undefined) + ?.logout_token; + if (typeof logoutToken !== "string" || !logoutToken) { + return res.status(400).json({ error: "missing logout_token" }); + } + + let issuer: string | null = null; + try { + const parts = logoutToken.split("."); + if (parts.length === 3) { + const claims = JSON.parse(Buffer.from(parts[1], "base64").toString()); + issuer = typeof claims.iss === "string" ? claims.iss : null; + } + } catch { + issuer = null; + } + + if (!issuer) { + return res.status(400).json({ error: "invalid logout_token" }); + } + + const provider = await resolveProviderByIssuer(issuer); + if (!provider) { + authLogger.warn("Back-channel logout for unknown issuer", { issuer }); + return res.status(400).json({ error: "unknown issuer" }); + } + + const claims = await validateLogoutToken(logoutToken, provider.config); + + if (claims.jti && isReplayedJti(claims.jti)) { + return res.status(200).json({ ok: true }); + } + + try { + await authManager.revokeSessionsByOidc({ + ssoProviderId: provider.providerDbId, + sub: claims.sub, + sid: claims.sid, + }); + } catch (err) { + authLogger.error("OIDC back-channel session revocation failed", err); + return res.status(500).json({ error: "logout processing failed" }); + } + + markLogoutJti(claims.jti); + + return res.status(200).json({ ok: true }); + } catch (err) { + authLogger.error("OIDC back-channel logout failed", err); + return res.status(400).json({ error: "invalid logout_token" }); + } +}); + /** * @openapi * /users/me: @@ -1812,25 +1754,29 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({ error: "Invalid userId" }); } try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const user = await findCurrentUser(userId); + if (!user) { authLogger.warn(`User not found for /users/me: ${userId}`); return res.status(401).json({ error: "User not found" }); } - const hasPassword = - user[0].passwordHash && user[0].passwordHash.trim() !== ""; - const hasOidc = user[0].isOidc && user[0].oidcIdentifier; + const hasPassword = user.passwordHash && user.passwordHash.trim() !== ""; + const hasOidc = user.isOidc && user.oidcIdentifier; const isDualAuth = hasPassword && hasOidc; + const showDonationModal = shouldShowDonationModal( + user.registeredAt, + !!user.donationModalDismissed, + ); + res.json({ - userId: user[0].id, - username: user[0].username, - is_admin: !!user[0].isAdmin, - is_oidc: !!user[0].isOidc, + userId: user.id, + username: user.username, + is_admin: !!user.isAdmin, + is_oidc: !!user.isOidc, is_dual_auth: isDualAuth, - totp_enabled: !!user[0].totpEnabled, - data_unlocked: authManager.isUserUnlocked(userId), + totp_enabled: !!user.totpEnabled, + show_donation_modal: showDonationModal, }); } catch (err) { authLogger.error("Failed to get username", err); @@ -1838,6 +1784,48 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { } }); +/** + * @openapi + * /users/me/dismiss-donation-modal: + * post: + * summary: Permanently dismiss the donation reminder modal + * description: Marks the donation reminder modal as dismissed for the currently authenticated user so it is never shown to them again. + * tags: + * - Users + * responses: + * 200: + * description: Donation modal dismissed. + * 401: + * description: Invalid userId or user not found. + * 500: + * description: Failed to dismiss donation modal. + */ +router.post( + "/me/dismiss-donation-modal", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + return res.status(401).json({ error: "Invalid userId" }); + } + try { + const updated = await createCurrentUserRepository().update(userId, { + donationModalDismissed: true, + }); + if (!updated) { + return res.status(401).json({ error: "User not found" }); + } + return res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to dismiss donation modal", err); + return res + .status(500) + .json({ error: "Failed to dismiss donation modal" }); + } + }, +); + /** * @openapi * /users/me/token: @@ -1881,10 +1869,7 @@ router.get("/me/token", authenticateJWT, (req: Request, res: Response) => { */ router.get("/setup-required", async (req, res) => { try { - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get(); - const count = (countResult as { count?: number })?.count || 0; + const count = await createCurrentUserRepository().countAll(); res.json({ setup_required: count === 0, @@ -1914,15 +1899,12 @@ router.get("/setup-required", async (req, res) => { router.get("/count", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user[0] || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Admin access required" }); } - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get(); - const count = (countResult as { count?: number })?.count || 0; + const count = await createCurrentUserRepository().countAll(); res.json({ count }); } catch (err) { authLogger.error("Failed to count users", err); @@ -1946,7 +1928,7 @@ router.get("/count", authenticateJWT, async (req, res) => { */ router.get("/db-health", requireAdmin, async (req, res) => { try { - db.$client.prepare("SELECT 1").get(); + await createCurrentUserRepository().countAll(); res.json({ status: "ok" }); } catch (err) { authLogger.error("DB health check failed", err); @@ -2007,17 +1989,18 @@ router.get("/registration-allowed", async (req, res) => { router.patch("/registration-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } - db.$client - .prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'") - .run(allowed ? "true" : "false"); + await createCurrentSettingsRepository().set( + "allow_registration", + allowed ? "true" : "false", + ); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set registration allowed", err); @@ -2027,11 +2010,11 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { router.get("/oidc-auto-provision", async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") - .get(); res.json({ - enabled: row ? (row as Record).value === "true" : false, + enabled: await createCurrentSettingsRepository().getBoolean( + "oidc_auto_provision", + false, + ), }); } catch (err) { authLogger.error("Failed to get OIDC auto-provision setting", err); @@ -2044,30 +2027,18 @@ router.get("/oidc-auto-provision", async (_req, res) => { router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "Invalid value for enabled" }); } - const existing = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") - .get(); - if (existing) { - db.$client - .prepare( - "UPDATE settings SET value = ? WHERE key = 'oidc_auto_provision'", - ) - .run(enabled ? "true" : "false"); - } else { - db.$client - .prepare( - "INSERT INTO settings (key, value) VALUES ('oidc_auto_provision', ?)", - ) - .run(enabled ? "true" : "false"); - } + await createCurrentSettingsRepository().set( + "oidc_auto_provision", + enabled ? "true" : "false", + ); res.json({ enabled }); } catch (err) { authLogger.error("Failed to set OIDC auto-provision", err); @@ -2091,13 +2062,11 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { */ router.get("/oidc-silent-login-default", async (_req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_silent_login_default'", - ) - .get(); res.json({ - enabled: row ? (row as Record).value === "true" : false, + enabled: await createCurrentSettingsRepository().getBoolean( + "oidc_silent_login_default", + false, + ), }); } catch (err) { authLogger.error("Failed to get OIDC silent login default", err); @@ -2138,34 +2107,18 @@ router.patch( async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "Invalid value for enabled" }); } - const existing = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_silent_login_default'", - ) - .get(); - if (existing) { - db.$client - .prepare( - "UPDATE settings SET value = ? WHERE key = 'oidc_silent_login_default'", - ) - .run(enabled ? "true" : "false"); - } else { - db.$client - .prepare( - "INSERT INTO settings (key, value) VALUES ('oidc_silent_login_default', ?)", - ) - .run(enabled ? "true" : "false"); - } - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await createCurrentSettingsRepository().set( + "oidc_silent_login_default", + enabled ? "true" : "false", + ); res.json({ enabled }); } catch (err) { authLogger.error("Failed to set OIDC silent login default", err); @@ -2229,8 +2182,8 @@ router.get("/password-login-allowed", async (req, res) => { router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; @@ -2238,23 +2191,19 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Invalid value for allowed" }); } if (!allowed) { - const totpRow = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE totp_enabled = 1") - .get() as { count?: number }; - if ((totpRow?.count || 0) > 0) { + const totpEnabledCount = + await createCurrentUserRepository().countTotpEnabled(); + if (totpEnabledCount > 0) { return res.status(409).json({ error: "Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.", }); } } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)", - ) - .run(allowed ? "true" : "false"); - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await createCurrentSettingsRepository().set( + "allow_password_login", + allowed ? "true" : "false", + ); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password login allowed", err); @@ -2315,19 +2264,18 @@ router.get("/password-reset-allowed", async (req, res) => { router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_reset', ?)", - ) - .run(allowed ? "true" : "false"); + await createCurrentSettingsRepository().set( + "allow_password_reset", + allowed ? "true" : "false", + ); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password reset allowed", err); @@ -2377,13 +2325,11 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { } try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await findCurrentUser(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (userRecord.isOidc) { return res.status(403).json({ error: @@ -2400,17 +2346,15 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { } if (userRecord.isAdmin) { - const adminCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") - .get(); - if (((adminCount as { count?: number })?.count || 0) <= 1) { + const adminCount = await createCurrentUserRepository().countAdmins(); + if (adminCount <= 1) { return res .status(403) .json({ error: "Cannot delete the last admin user" }); } } - await db.delete(users).where(eq(users.id, userId)); + await createCurrentUserRepository().delete(userId); authLogger.success(`User account deleted: ${userRecord.username}`); res.json({ message: "Account deleted successfully" }); @@ -2469,12 +2413,12 @@ router.post("/change-password", authenticateJWT, async (req, res) => { .json({ error: "Old and new passwords are required." }); } - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const user = await findCurrentUser(userId); + if (!user) { return res.status(404).json({ error: "User not found" }); } - const isMatch = await bcrypt.compare(oldPassword, user[0].passwordHash); + const isMatch = await bcrypt.compare(oldPassword, user.passwordHash); if (!isMatch) { authLogger.warn("Password change failed - old password incorrect", { operation: "password_change_failed", @@ -2496,10 +2440,9 @@ router.post("/change-password", authenticateJWT, async (req, res) => { } const password_hash = await bcrypt.hash(newPassword, 10); - await db - .update(users) - .set({ passwordHash: password_hash }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + passwordHash: password_hash, + }); authManager.logoutUser(userId); authLogger.success("Password changed successfully", { @@ -2508,14 +2451,9 @@ router.post("/change-password", authenticateJWT, async (req, res) => { }); const { ipAddress: pwIp, userAgent: pwUa } = getRequestMeta(req); - const pwUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: pwUser[0]?.username ?? userId, + username: user.username ?? userId, action: "change_password", resourceType: "user", resourceId: userId, @@ -2579,35 +2517,30 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { } try { - const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(userId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } - if (adminUser[0].username === username) { + if (adminUser.username === username) { return res.status(400).json({ error: "Cannot delete your own account" }); } - const targetUser = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (!targetUser || targetUser.length === 0) { + const targetUser = await userRepository.findByUsername(username); + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - if (targetUser[0].isAdmin) { - const adminCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") - .get(); - if (((adminCount as { count?: number })?.count || 0) <= 1) { + if (targetUser.isAdmin) { + if ((await userRepository.countAdmins()) <= 1) { return res .status(403) .json({ error: "Cannot delete the last admin user" }); } } - const targetUserId = targetUser[0].id; + const targetUserId = targetUser.id; await deleteUserAndRelatedData(targetUserId); @@ -2619,14 +2552,9 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { }); const { ipAddress: deleteIp, userAgent: deleteUa } = getRequestMeta(req); - const delAdminRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: delAdminRecord[0]?.username ?? userId, + username: adminUser.username ?? userId, action: "delete_user", resourceType: "user", resourceId: targetUserId, diff --git a/src/backend/database/routes/vault.ts b/src/backend/database/routes/vault.ts index 502b34eb..d70b3050 100644 --- a/src/backend/database/routes/vault.ts +++ b/src/backend/database/routes/vault.ts @@ -1,12 +1,14 @@ import express from "express"; import type { Request, Response } from "express"; -import { desc, eq, or } from "drizzle-orm"; -import { db } from "../db/index.js"; -import { users, vaultProfiles } from "../db/schema.js"; +import { + createCurrentVaultProfileRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { completeVaultAuth } from "../../ssh/vault-oidc-auth.js"; +import { completeVaultAuth } from "../../hosts/vault-oidc-auth.js"; const router = express.Router(); @@ -19,12 +21,8 @@ function isNonEmptyString(val: unknown): val is string { async function userIsAdmin(userId: string): Promise { try { - const rows = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - return !!rows[0]?.isAdmin; + const user = await createCurrentUserRepository().findById(userId); + return !!user?.isAdmin; } catch { return false; } @@ -148,13 +146,8 @@ router.get( async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select() - .from(vaultProfiles) - .where( - or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)), - ) - .orderBy(desc(vaultProfiles.updatedAt)); + const rows = + await createCurrentVaultProfileRepository().listVisibleToUser(userId); res.json( rows.map((r) => formatProfile(r as Record, userId)), ); @@ -253,28 +246,25 @@ router.post( } try { - const inserted = await db - .insert(vaultProfiles) - .values({ - userId, - name: name.trim(), - description: description?.trim() || null, - folder: folder?.trim() || null, - tags: Array.isArray(tags) ? tags.join(",") : tags || "", - vaultAddr: vaultAddr.trim(), - vaultNamespace: vaultNamespace?.trim() || null, - oidcMount: oidcMount?.trim() || null, - oidcRole: oidcRole?.trim() || null, - sshMount: sshMount?.trim() || null, - sshRole: sshRole.trim(), - validPrincipals: validPrincipals?.trim() || null, - keyType: keyType?.trim() || null, - shared: wantShared, - }) - .returning(); + const inserted = await createCurrentVaultProfileRepository().create({ + userId, + name: name.trim(), + description: description?.trim() || null, + folder: folder?.trim() || null, + tags: Array.isArray(tags) ? tags.join(",") : tags || "", + vaultAddr: vaultAddr.trim(), + vaultNamespace: vaultNamespace?.trim() || null, + oidcMount: oidcMount?.trim() || null, + oidcRole: oidcRole?.trim() || null, + sshMount: sshMount?.trim() || null, + sshRole: sshRole.trim(), + validPrincipals: validPrincipals?.trim() || null, + keyType: keyType?.trim() || null, + shared: wantShared, + }); res .status(201) - .json(formatProfile(inserted[0] as Record, userId)); + .json(formatProfile(inserted as Record, userId)); } catch (err) { authLogger.error("Failed to create vault profile", err); res.status(500).json({ error: "Failed to create vault profile" }); @@ -324,22 +314,19 @@ router.put( } try { - const existing = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, id)) - .limit(1); - if (!existing.length) { + const repository = createCurrentVaultProfileRepository(); + const existing = await repository.findById(id); + if (!existing) { return res.status(404).json({ error: "Profile not found" }); } - if (existing[0].userId !== userId) { + if (existing.userId !== userId) { return res .status(403) .json({ error: "Only the owner can edit this profile" }); } const body = req.body; - const fields: Record = { + const fields: VaultProfileUpdateInput = { updatedAt: new Date().toISOString(), }; if (body.name !== undefined) fields.name = body.name?.trim(); @@ -376,12 +363,11 @@ router.put( fields.shared = !!body.shared; } - const updated = await db - .update(vaultProfiles) - .set(fields) - .where(eq(vaultProfiles.id, id)) - .returning(); - res.json(formatProfile(updated[0] as Record, userId)); + const updated = await repository.updateById(id, fields); + if (!updated) { + return res.status(404).json({ error: "Profile not found" }); + } + res.json(formatProfile(updated as Record, userId)); } catch (err) { authLogger.error("Failed to update vault profile", err); res.status(500).json({ error: "Failed to update vault profile" }); @@ -425,20 +411,17 @@ router.delete( return res.status(400).json({ error: "Invalid profile id" }); } try { - const existing = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, id)) - .limit(1); - if (!existing.length) { + const repository = createCurrentVaultProfileRepository(); + const existing = await repository.findById(id); + if (!existing) { return res.status(404).json({ error: "Profile not found" }); } - if (existing[0].userId !== userId) { + if (existing.userId !== userId) { return res .status(403) .json({ error: "Only the owner can delete this profile" }); } - await db.delete(vaultProfiles).where(eq(vaultProfiles.id, id)); + await repository.deleteById(id); res.json({ success: true }); } catch (err) { authLogger.error("Failed to delete vault profile", err); diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts deleted file mode 100644 index 746007af..00000000 --- a/src/backend/guacamole/guacamole-server.ts +++ /dev/null @@ -1,139 +0,0 @@ -import GuacamoleLite from "guacamole-lite"; -import { guacLogger } from "../utils/logger.js"; -import { GuacamoleTokenService } from "./token-service.js"; -import { getDb } from "../database/db/index.js"; -import { resolveGuacdOptions } from "../utils/guacd-config.js"; - -const tokenService = GuacamoleTokenService.getInstance(); - -function readGuacdOptions(): { host: string; port: number } { - let dbUrl: string | undefined; - try { - const db = getDb(); - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; - dbUrl = urlRow?.value; - } catch { - // DB not available yet, use env var defaults - } - return resolveGuacdOptions(dbUrl); -} - -const GUAC_WS_PORT = 30008; - -const websocketOptions = { - port: GUAC_WS_PORT, -}; - -const clientOptions = { - crypt: { - cypher: "AES-256-CBC", - key: tokenService.getEncryptionKey(), - }, - log: { - level: "ERRORS", - stdLog: (...args: unknown[]) => { - guacLogger.info(args.join(" ")); - }, - errorLog: (...args: unknown[]) => { - guacLogger.error(args.join(" ")); - }, - }, - allowedUnencryptedConnectionSettings: { - rdp: ["width", "height"], - vnc: ["width", "height"], - telnet: ["width", "height"], - }, - connectionDefaultSettings: { - rdp: { - security: "any", - "ignore-cert": true, - "enable-wallpaper": false, - "enable-font-smoothing": true, - "enable-desktop-composition": false, - "disable-audio": false, - "enable-drive": false, - "resize-method": "display-update", - width: 1280, - height: 720, - dpi: 96, - audio: ["audio/L16"], - }, - vnc: { - "swap-red-blue": false, - cursor: "remote", - security: "any", - width: 1280, - height: 720, - }, - telnet: { - "terminal-type": "xterm-256color", - }, - }, -}; - -const _origConsoleLog = console.log; -console.log = (...args: unknown[]) => { - const msg = args[0]; - if (typeof msg === "string" && msg.startsWith("New client connection")) - return; - _origConsoleLog(...args); -}; - -function createGuacServer(): GuacamoleLite { - const guacdOptions = readGuacdOptions(); - const server = new GuacamoleLite( - websocketOptions, - guacdOptions, - clientOptions, - ); - - server.on( - "open", - (clientConnection: { connectionSettings?: Record }) => { - guacLogger.info("Guacamole connection opened", { - operation: "guac_connection_open", - type: clientConnection.connectionSettings?.type, - }); - }, - ); - - server.on( - "close", - (clientConnection: { connectionSettings?: Record }) => { - guacLogger.info("Guacamole connection closed", { - operation: "guac_connection_close", - type: clientConnection.connectionSettings?.type, - }); - }, - ); - - server.on( - "error", - ( - clientConnection: { connectionSettings?: Record }, - error: Error, - ) => { - guacLogger.error("Guacamole connection error", error, { - operation: "guac_connection_error", - type: clientConnection.connectionSettings?.type, - }); - }, - ); - - return server; -} - -let guacServer = createGuacServer(); - -export async function restartGuacServer(): Promise { - try { - guacServer.close(); - } catch (err) { - guacLogger.error("Error closing guac server during restart", err as Error); - } - guacServer = createGuacServer(); -} - -export { guacServer, tokenService }; diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/hosts/auth-manager.ts similarity index 94% rename from src/backend/ssh/auth-manager.ts rename to src/backend/hosts/auth-manager.ts index 0d79a845..9aceab71 100644 --- a/src/backend/ssh/auth-manager.ts +++ b/src/backend/hosts/auth-manager.ts @@ -1,9 +1,6 @@ import type { WebSocket } from "ws"; import { sshLogger, authLogger } from "../utils/logger.js"; -import { getDb } from "../database/db/index.js"; -import { sshCredentials } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; interface ResolvedCredentials { username: string; password?: string; @@ -59,22 +56,13 @@ export class SSHAuthManager { }; if (hostConfig.credentialId) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, hostConfig.credentialId), - eq(sshCredentials.userId, this.context.userId), - ), - ), - "ssh_credentials", - this.context.userId, - ); + const cred = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + hostConfig.credentialId, + this.context.userId, + ); - if (credentials.length > 0) { - const cred = credentials[0]; + if (cred) { resolvedCredentials = { username: (cred.username as string) || hostConfig.username, password: (cred.password as string) || undefined, diff --git a/src/backend/ssh/file-manager-log.ts b/src/backend/hosts/connection-log.ts similarity index 100% rename from src/backend/ssh/file-manager-log.ts rename to src/backend/hosts/connection-log.ts diff --git a/src/backend/ssh/credential-username.ts b/src/backend/hosts/credential-username.ts similarity index 70% rename from src/backend/ssh/credential-username.ts rename to src/backend/hosts/credential-username.ts index 26f6d982..d3ca4ed7 100644 --- a/src/backend/ssh/credential-username.ts +++ b/src/backend/hosts/credential-username.ts @@ -21,6 +21,19 @@ export function pickResolvedUsername( return cred; } +/** + * A host can keep a per-host password while using a shared key credential. + * Prefer that host-specific value and fall back to the credential password. + */ +export function pickResolvedPassword( + hostPassword: unknown, + credentialPassword: unknown, +): string | undefined { + if (isNonEmptyString(hostPassword)) return hostPassword; + if (isNonEmptyString(credentialPassword)) return credentialPassword; + return undefined; +} + /** * Expands the `$oidc.preferred_username` placeholder in an SSH username to the * connecting user's OIDC identifier. Returns the username unchanged if it does @@ -35,18 +48,10 @@ export async function expandOidcUsername( } try { - const { getDb } = await import("../database/db/index.js"); - const { users } = await import("../database/db/schema.js"); - const { eq } = await import("drizzle-orm"); - - const db = getDb(); - const rows = await db - .select({ oidcIdentifier: users.oidcIdentifier }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - - const oidcIdentifier = rows[0]?.oidcIdentifier; + const { createCurrentUserRepository } = + await import("../database/repositories/factory.js"); + const user = await createCurrentUserRepository().findById(userId); + const oidcIdentifier = user?.oidcIdentifier; if (!oidcIdentifier) return username; return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier); diff --git a/src/backend/ssh/docker-console.ts b/src/backend/hosts/docker/console.ts similarity index 94% rename from src/backend/ssh/docker-console.ts rename to src/backend/hosts/docker/console.ts index b6e80845..46a3c9bf 100644 --- a/src/backend/ssh/docker-console.ts +++ b/src/backend/hosts/docker/console.ts @@ -1,19 +1,17 @@ import { Client as SSHClient } from "ssh2"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { WebSocketServer, WebSocket } from "ws"; -import { AuthManager } from "../utils/auth-manager.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { and, eq } from "drizzle-orm"; -import { getDb } from "../database/db/index.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { systemLogger } from "../utils/logger.js"; -import type { SSHHost } from "../../types/index.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; +import { systemLogger } from "../../utils/logger.js"; +import type { SSHHost } from "../../../types/index.js"; +import { applyAgentAuth } from "../terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, type ContainerRuntime, } from "./container-runtime.js"; +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; const sshLogger = systemLogger; @@ -91,24 +89,17 @@ async function createJumpHostChain( } let currentClient: SSHClient | null = null; + const repository = createCurrentHostResolutionRepository(); for (let i = 0; i < jumpHosts.length; i++) { const jumpHostId = jumpHosts[i].hostId; - const jumpHostData = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); - - if (jumpHostData.length === 0) { + const resolvedJumpHost = await repository.findHostById(jumpHostId, userId); + if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) { throw new Error(`Jump host ${jumpHostId} not found`); } - const jumpHost = jumpHostData[0] as unknown as SSHHost; + const jumpHost = resolvedJumpHost as unknown as SSHHost; if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) { try { jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts); @@ -133,22 +124,12 @@ async function createJumpHostChain( }; if (jumpHost.credentialId) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, jumpHost.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", + const credential = await repository.findCredentialByIdForUser( + jumpHost.credentialId as number, userId, ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { resolvedCredentials = { password: credential.password as string | undefined, sshKey: (credential.key || credential.privateKey) as @@ -276,6 +257,10 @@ async function createJumpHostChain( }); } + if (!config.sock) { + await resolveSshConnectConfigHost(config); + } + await new Promise((resolve, reject) => { client.on("ready", () => resolve()); client.on("error", reject); @@ -398,7 +383,7 @@ wss.on("connection", async (ws: WebSocket, req) => { try { // Resolve host with credentials server-side - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById(hostId, userId); if (!resolvedHost) { @@ -489,6 +474,10 @@ wss.on("connection", async (ws: WebSocket, req) => { } } + if (!config.sock) { + await resolveSshConnectConfigHost(config); + } + await new Promise((resolve, reject) => { client.on("ready", () => resolve()); client.on("error", reject); diff --git a/src/backend/ssh/docker-container-routes.ts b/src/backend/hosts/docker/container-routes.ts similarity index 97% rename from src/backend/ssh/docker-container-routes.ts rename to src/backend/hosts/docker/container-routes.ts index 9bb115ab..75d5f924 100644 --- a/src/backend/ssh/docker-container-routes.ts +++ b/src/backend/hosts/docker/container-routes.ts @@ -1,5 +1,5 @@ import type express from "express"; -import { logger } from "../utils/logger.js"; +import { logger } from "../../utils/logger.js"; import { containerCommand, type ContainerRuntime, @@ -9,6 +9,7 @@ const sshLogger = logger; type DockerSession = { isConnected: boolean; + userId?: string; lastActive: number; activeOperations: number; hostId?: number; @@ -46,6 +47,10 @@ export function registerDockerContainerRoutes( ): void { const getRuntime = (session: DockerSession) => session.containerRuntime ?? "docker"; + const getOwnedSession = (sessionId: string, userId: string) => { + const session = sshSessions[sessionId]; + return session?.userId === userId ? session : undefined; + }; /** * @openapi @@ -89,7 +94,7 @@ export function registerDockerContainerRoutes( }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -189,7 +194,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -287,7 +292,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -387,7 +392,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -487,7 +492,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -587,7 +592,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -687,7 +692,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -792,7 +797,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -925,7 +930,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ @@ -1040,7 +1045,7 @@ export function registerDockerContainerRoutes( return res.status(401).json({ error: "Authentication required" }); } - const session = sshSessions[sessionId]; + const session = getOwnedSession(sessionId, userId); if (!session || !session.isConnected) { return res.status(400).json({ diff --git a/src/backend/ssh/container-runtime.ts b/src/backend/hosts/docker/container-runtime.ts similarity index 100% rename from src/backend/ssh/container-runtime.ts rename to src/backend/hosts/docker/container-runtime.ts diff --git a/src/backend/hosts/docker/index.ts b/src/backend/hosts/docker/index.ts new file mode 100644 index 00000000..c6f9e3a9 --- /dev/null +++ b/src/backend/hosts/docker/index.ts @@ -0,0 +1,70 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import { createCorsMiddleware } from "../../utils/cors-config.js"; +import { logger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { registerDockerContainerRoutes } from "./container-routes.js"; +import { + sshSessions, + pendingTOTPSessions, + cleanupSession, + executeDockerCommand, +} from "./session-manager.js"; +import { + DOCKER_TIMESTAMP_RE, + getRequestUserId, + registerDockerSshRoutes, +} from "./routes.js"; + +const sshLogger = logger; + +const app = express(); + +app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); + +app.use(cookieParser()); +app.use(express.json({ limit: "100mb" })); +app.use(express.urlencoded({ limit: "100mb", extended: true })); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); + +const authManager = AuthManager.getInstance(); +app.use(authManager.createAuthMiddleware()); + +registerDockerSshRoutes(app); + +registerDockerContainerRoutes(app, { + sshSessions, + pendingTOTPSessions, + getRequestUserId, + executeDockerCommand, + dockerTimestampPattern: DOCKER_TIMESTAMP_RE, +}); + +const PORT = 30007; + +app.listen(PORT, async () => { + try { + await authManager.initialize(); + } catch (err) { + sshLogger.error("Failed to initialize Docker backend", err, { + operation: "startup", + }); + } +}); + +process.on("SIGINT", () => { + Object.keys(sshSessions).forEach((sessionId) => { + cleanupSession(sessionId); + }); + process.exit(0); +}); + +process.on("SIGTERM", () => { + Object.keys(sshSessions).forEach((sessionId) => { + cleanupSession(sessionId); + }); + process.exit(0); +}); diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts new file mode 100644 index 00000000..2cebf272 --- /dev/null +++ b/src/backend/hosts/docker/routes.ts @@ -0,0 +1,1776 @@ +import express from "express"; +import axios from "axios"; +import { Client as SSHClient } from "ssh2"; +import { logger } from "../../utils/logger.js"; +import { + createCurrentCredentialRepository, + createCurrentHostRepository, + createCurrentHostResolutionRepository, +} from "../../database/repositories/factory.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; +import { createConnectionLog } from "../connection-log.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { + createSocks5Connection, + type SOCKS5Config, +} from "../../utils/socks5-helper.js"; +import type { SSHHost, ProxyNode } from "../../../types/index.js"; +import type { + LogEntry, + ConnectionStage, +} from "../../../types/connection-log.js"; +import { SSHHostKeyVerifier } from "../host-key-verifier.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { applyAgentAuth } from "../terminal-auth-helpers.js"; +import { + containerCommand, + getContainerRuntimeConfig, + getRuntimeLabel, +} from "./container-runtime.js"; +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { + type SSHSession, + sshSessions, + pendingTOTPSessions, + cleanupSession, + scheduleSessionCleanup, + executeDockerCommand, +} from "./session-manager.js"; + +const sshLogger = logger; +const authManager = AuthManager.getInstance(); + +const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/; + +export function getRequestUserId(req: express.Request): string | undefined { + return (req as AuthenticatedRequest).userId; +} + +export function registerDockerSshRoutes(app: express.Express): void { + app.param("containerId", (req, res, next, value) => { + if (!CONTAINER_ID_RE.test(value)) { + return res.status(400).json({ error: "Invalid container ID" }); + } + next(); + }); + + /** + * @openapi + * /docker/ssh/connect: + * post: + * summary: Establish SSH session for Docker + * description: Establishes an SSH session to a host for Docker operations. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * responses: + * 200: + * description: SSH connection established. + * 400: + * description: Missing sessionId or hostId. + * 401: + * description: Authentication required. + * 403: + * description: Docker is not enabled for this host. + * 404: + * description: Host not found. + * 500: + * description: SSH connection failed. + */ + app.post("/docker/ssh/connect", async (req, res) => { + const { + sessionId, + hostId, + userProvidedPassword, + userProvidedSshKey, + userProvidedKeyPassword, + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain, + } = req.body; + const userId = getRequestUserId(req); + + const connectionLogs: Array> = []; + + if (!userId) { + sshLogger.error("Docker SSH connection rejected: no authenticated user", { + operation: "docker_connect_auth", + sessionId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_connecting", + "Authentication required", + ), + ); + return res + .status(401) + .json({ error: "Authentication required", connectionLogs }); + } + + if (!DataCrypto.canUserAccessData(userId)) { + connectionLogs.push( + createConnectionLog("error", "docker_connecting", "Session expired"), + ); + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + connectionLogs, + }); + } + + if (!sessionId || !hostId) { + sshLogger.warn("Missing Docker SSH connection parameters", { + operation: "docker_connect", + sessionId, + hasHostId: !!hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_connecting", + "Missing connection parameters", + ), + ); + return res + .status(400) + .json({ error: "Missing sessionId or hostId", connectionLogs }); + } + + connectionLogs.push( + createConnectionLog( + "info", + "docker_connecting", + "Initiating Docker SSH connection", + ), + ); + + try { + const hostRecord = + await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); + + if (!hostRecord) { + connectionLogs.push( + createConnectionLog("error", "docker_connecting", "Host not found"), + ); + return res + .status(404) + .json({ error: "Host not found", connectionLogs }); + } + + const host = hostRecord as unknown as SSHHost; + + if (host.userId !== userId) { + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const permissionManager = PermissionManager.getInstance(); + const accessInfo = await permissionManager.canAccessHost( + userId, + hostId, + "connect", + ); + + if (!accessInfo.hasAccess) { + sshLogger.warn("User does not have access to host", { + operation: "docker_connect", + hostId, + userId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_connecting", + "Access denied to host", + ), + ); + return res + .status(403) + .json({ error: "Access denied", connectionLogs }); + } + } + if (typeof host.jumpHosts === "string" && host.jumpHosts) { + try { + host.jumpHosts = JSON.parse(host.jumpHosts); + } catch (e) { + sshLogger.error("Failed to parse jump hosts", e, { + hostId: host.id, + }); + host.jumpHosts = []; + } + } + if (typeof host.terminalConfig === "string" && host.terminalConfig) { + try { + host.terminalConfig = JSON.parse(host.terminalConfig as string); + } catch { + host.terminalConfig = undefined; + } + } + const { runtime: containerRuntime } = getContainerRuntimeConfig( + host.dockerConfig, + ); + + if (!host.enableDocker) { + sshLogger.warn("Docker not enabled for host", { + operation: "docker_connect", + hostId, + userId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_connecting", + "Docker is not enabled for this host", + ), + ); + return res.status(403).json({ + error: + "Docker is not enabled for this host. Enable it in Host Settings.", + code: "DOCKER_DISABLED", + connectionLogs, + }); + } + + connectionLogs.push( + createConnectionLog( + "info", + "docker_auth", + "Resolving authentication credentials", + ), + ); + + if (sshSessions[sessionId]) { + cleanupSession(sessionId); + } + + if (pendingTOTPSessions[sessionId]) { + try { + pendingTOTPSessions[sessionId].client.end(); + } catch { + // expected + } + delete pendingTOTPSessions[sessionId]; + } + + let resolvedCredentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + authType?: string; + } = { + password: host.password, + sshKey: host.key, + keyPassword: host.keyPassword, + authType: host.authType, + }; + + if (userProvidedPassword) { + resolvedCredentials.password = userProvidedPassword; + } + if (userProvidedSshKey) { + resolvedCredentials.sshKey = userProvidedSshKey; + resolvedCredentials.authType = "key"; + } + if (userProvidedKeyPassword) { + resolvedCredentials.keyPassword = userProvidedKeyPassword; + } + + if (host.credentialId) { + const ownerId = host.userId; + + if (userId !== ownerId) { + try { + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedCred = + await SharedHostSecretsManager.getInstance().getSecretForUser( + host.id, + userId, + "ssh", + ); + + if (sharedCred) { + resolvedCredentials = { + password: sharedCred.password, + sshKey: sharedCred.key, + keyPassword: sharedCred.keyPassword, + authType: sharedCred.authType, + }; + } + } catch (error) { + sshLogger.error("Failed to resolve shared credential", error, { + operation: "docker_connect", + hostId, + userId, + }); + } + } else { + const credential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + host.credentialId as number, + ); + + if (credential) { + resolvedCredentials = { + password: credential.password as string | undefined, + sshKey: (credential.key || credential.privateKey) as + | string + | undefined, + keyPassword: credential.keyPassword as string | undefined, + authType: credential.authType as string | undefined, + }; + } + } + } + + const client = new SSHClient(); + + const config: Record = { + host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, + port: host.port || 22, + username: host.username, + tryKeyboard: true, + keepaliveInterval: + typeof host.terminalConfig?.keepaliveInterval === "number" + ? host.terminalConfig.keepaliveInterval * 1000 + : 60000, + keepaliveCountMax: + typeof host.terminalConfig?.keepaliveCountMax === "number" + ? host.terminalConfig.keepaliveCountMax + : 5, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + hostVerifier: await SSHHostKeyVerifier.createHostVerifier( + hostId, + host.ip, + host.port || 22, + null, + userId, + false, + ), + }; + + if ( + resolvedCredentials.authType === "none" || + resolvedCredentials.authType === "tailscale" || + resolvedCredentials.authType === "warpgate" + ) { + // Tailscale SSH, "none", and Warpgate auth: no static credentials + } else if (resolvedCredentials.authType === "password") { + if (resolvedCredentials.password) { + config.password = resolvedCredentials.password; + } + } else if (resolvedCredentials.authType === "opkssh") { + try { + const { getOPKSSHToken } = await import("../opkssh-auth.js"); + const token = await getOPKSSHToken(userId, hostId); + + if (!token) { + connectionLogs.push( + createConnectionLog( + "error", + "docker_auth", + "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", + ), + ); + return res.status(401).json({ + error: + "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", + requiresOPKSSHAuth: true, + connectionLogs, + }); + } + + const { setupOPKSSHCertAuth } = + await import("../opkssh-cert-auth.js"); + await setupOPKSSHCertAuth( + config as import("ssh2").ConnectConfig, + client, + token, + host.username, + ); + connectionLogs.push( + createConnectionLog( + "info", + "docker_auth", + "Using OPKSSH certificate authentication", + ), + ); + } catch (opksshError) { + sshLogger.error("OPKSSH authentication error for Docker", { + operation: "docker_connect", + sessionId, + hostId, + error: + opksshError instanceof Error + ? opksshError.message + : "Unknown error", + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_auth", + `OPKSSH authentication failed: ${opksshError instanceof Error ? opksshError.message : "Unknown error"}`, + ), + ); + return res.status(500).json({ + error: "OPKSSH authentication failed", + connectionLogs, + }); + } + } else if ( + resolvedCredentials.authType === "key" && + resolvedCredentials.sshKey + ) { + try { + config.privateKey = preparePrivateKeyForSSH2( + resolvedCredentials.sshKey, + resolvedCredentials.keyPassword, + ); + if (resolvedCredentials.keyPassword) { + config.passphrase = resolvedCredentials.keyPassword; + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Invalid private key format"; + sshLogger.error("SSH key processing error", error, { + operation: "docker_connect", + sessionId, + hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_auth", + `SSH key processing error: ${message}`, + ), + ); + return res.status(400).json({ + error: `SSH key format error: ${message}`, + connectionLogs, + }); + } + } else if (resolvedCredentials.authType === "key") { + sshLogger.error( + "SSH key authentication requested but no key provided", + { + operation: "docker_connect", + sessionId, + hostId, + }, + ); + connectionLogs.push( + createConnectionLog( + "error", + "docker_auth", + "SSH key authentication requested but no key provided", + ), + ); + return res.status(400).json({ + error: "SSH key authentication requested but no key provided", + connectionLogs, + }); + } else if (resolvedCredentials.authType === "agent") { + const result = await applyAgentAuth( + config, + host.terminalConfig as unknown as Record | undefined, + ); + if ("error" in result) { + connectionLogs.push( + createConnectionLog("error", "docker_auth", result.error), + ); + return res.status(400).json({ error: result.error, connectionLogs }); + } + connectionLogs.push( + createConnectionLog( + "info", + "docker_auth", + "Using SSH agent authentication", + ), + ); + } + + let responseSent = false; + connectionLogs.push( + createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`), + ); + + connectionLogs.push( + createConnectionLog( + "info", + "tcp", + `Connecting to ${host.ip}:${host.port || 22}`, + ), + ); + + connectionLogs.push( + createConnectionLog("info", "handshake", "Initiating SSH handshake"), + ); + + if (resolvedCredentials.authType === "password") { + connectionLogs.push( + createConnectionLog("info", "auth", "Authenticating with password"), + ); + } else if (resolvedCredentials.authType === "key") { + connectionLogs.push( + createConnectionLog("info", "auth", "Authenticating with SSH key"), + ); + } else if (resolvedCredentials.authType === "agent") { + connectionLogs.push( + createConnectionLog("info", "auth", "Authenticating with SSH agent"), + ); + } else if ( + resolvedCredentials.authType === "none" || + resolvedCredentials.authType === "tailscale" + ) { + connectionLogs.push( + createConnectionLog( + "info", + "auth", + "Attempting keyboard-interactive authentication", + ), + ); + } + + client.on("ready", () => { + if (responseSent) return; + responseSent = true; + + connectionLogs.push( + createConnectionLog( + "success", + "connected", + "SSH connection established successfully", + ), + ); + + const session: SSHSession = { + client, + isConnected: true, + lastActive: Date.now(), + activeOperations: 0, + hostId, + userId, + containerRuntime, + }; + + sshSessions[sessionId] = session; + scheduleSessionCleanup(sessionId); + + client.exec("ver", (err, stream) => { + if (!err && stream) { + let output = ""; + stream.on("data", (d: Buffer) => { + output += d.toString(); + }); + stream.on("close", () => { + if (output.toLowerCase().includes("windows")) { + session.isWindows = true; + } + }); + stream.stderr.on("data", () => {}); + } + }); + + res.json({ + success: true, + message: "SSH connection established", + connectionLogs, + }); + }); + + client.on("error", (err) => { + if (responseSent) { + sshLogger.error( + "Docker SSH connection error after response sent", + err, + { + operation: "docker_connect_after_response", + sessionId, + hostId, + userId, + }, + ); + + if (pendingTOTPSessions[sessionId]) { + delete pendingTOTPSessions[sessionId]; + } + return; + } + responseSent = true; + + sshLogger.error("Docker SSH connection failed", err, { + operation: "docker_connect", + sessionId, + hostId, + userId, + }); + + let errorStage: ConnectionStage; + if ( + err.message.includes("ENOTFOUND") || + err.message.includes("getaddrinfo") + ) { + errorStage = "dns"; + connectionLogs.push( + createConnectionLog( + "error", + errorStage, + `DNS resolution failed: ${err.message}`, + ), + ); + } else if ( + err.message.includes("ECONNREFUSED") || + err.message.includes("ETIMEDOUT") + ) { + errorStage = "tcp"; + connectionLogs.push( + createConnectionLog( + "error", + errorStage, + `TCP connection failed: ${err.message}`, + ), + ); + } else if ( + err.message.includes("handshake") || + err.message.includes("key exchange") + ) { + errorStage = "handshake"; + connectionLogs.push( + createConnectionLog( + "error", + errorStage, + `SSH handshake failed: ${err.message}`, + ), + ); + } else if ( + err.message.includes("authentication") || + err.message.includes("Authentication") + ) { + errorStage = "auth"; + connectionLogs.push( + createConnectionLog( + "error", + errorStage, + `Authentication failed: ${err.message}`, + ), + ); + } else if (err.message.includes("verification failed")) { + errorStage = "handshake"; + connectionLogs.push( + createConnectionLog( + "error", + errorStage, + `SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`, + ), + ); + } else { + connectionLogs.push( + createConnectionLog( + "error", + "error", + `SSH connection failed: ${err.message}`, + ), + ); + } + + if ( + (resolvedCredentials.authType === "none" || + resolvedCredentials.authType === "tailscale") && + (err.message.includes("authentication") || + err.message.includes( + "All configured authentication methods failed", + )) + ) { + res.json({ + status: "auth_required", + reason: "no_keyboard", + connectionLogs, + }); + } else { + res.status(500).json({ + success: false, + message: err.message || "SSH connection failed", + connectionLogs, + }); + } + }); + + client.on("close", () => { + if (sshSessions[sessionId]) { + sshSessions[sessionId].isConnected = false; + cleanupSession(sessionId); + } + + if (pendingTOTPSessions[sessionId]) { + delete pendingTOTPSessions[sessionId]; + } + }); + + client.on( + "keyboard-interactive", + ( + name: string, + instructions: string, + instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + const promptTexts = prompts.map((p) => p.prompt); + + const warpgatePattern = /warpgate\s+authentication/i; + const isWarpgate = + warpgatePattern.test(name) || + warpgatePattern.test(instructions) || + promptTexts.some((p) => warpgatePattern.test(p)); + + if (isWarpgate) { + const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; + const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); + const keyMatch = fullText.match( + /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, + ); + + if (urlMatch) { + if (responseSent) return; + responseSent = true; + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip: host.ip, + port: host.port || 22, + username: host.username, + userId, + prompts, + totpPromptIndex: -1, + resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, + isWarpgate: true, + containerRuntime, + }; + + connectionLogs.push( + createConnectionLog( + "info", + "docker_auth", + "Warpgate authentication required", + ), + ); + + res.json({ + requires_warpgate: true, + sessionId, + url: urlMatch[0], + securityKey: keyMatch ? keyMatch[1] : "N/A", + connectionLogs, + }); + return; + } + } + + const totpPromptIndex = prompts.findIndex((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPromptIndex !== -1) { + if (responseSent) { + const responses = prompts.map((p) => { + if ( + /password/i.test(p.prompt) && + resolvedCredentials.password + ) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + return; + } + responseSent = true; + + if (pendingTOTPSessions[sessionId]) { + const responses = prompts.map((p) => { + if ( + /password/i.test(p.prompt) && + resolvedCredentials.password + ) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + return; + } + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip: host.ip, + port: host.port || 22, + username: host.username, + userId, + prompts, + totpPromptIndex, + resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, + containerRuntime, + }; + + connectionLogs.push( + createConnectionLog( + "info", + "docker_auth", + "TOTP verification required", + ), + ); + + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[totpPromptIndex].prompt, + connectionLogs, + }); + } else { + const passwordPromptIndex = prompts.findIndex((p) => + /password/i.test(p.prompt), + ); + + if (resolvedCredentials.authType === "warpgate") { + finish(prompts.map(() => "")); + return; + } + + if ( + (resolvedCredentials.authType === "none" || + resolvedCredentials.authType === "tailscale") && + passwordPromptIndex !== -1 + ) { + if (responseSent) return; + responseSent = true; + client.end(); + res.json({ + status: "auth_required", + reason: "no_keyboard", + }); + return; + } + + const hasStoredPassword = + resolvedCredentials.password && + resolvedCredentials.authType !== "none"; + + if (!hasStoredPassword && passwordPromptIndex !== -1) { + if (responseSent) { + const responses = prompts.map((p) => { + if ( + /password/i.test(p.prompt) && + resolvedCredentials.password + ) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + return; + } + responseSent = true; + + if (pendingTOTPSessions[sessionId]) { + const responses = prompts.map((p) => { + if ( + /password/i.test(p.prompt) && + resolvedCredentials.password + ) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + return; + } + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip: host.ip, + port: host.port || 22, + username: host.username, + userId, + prompts, + totpPromptIndex: passwordPromptIndex, + resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, + containerRuntime, + }; + + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[passwordPromptIndex].prompt, + isPassword: true, + }); + return; + } + + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + } + }, + ); + + const proxyConfig: SOCKS5Config | null = + useSocks5 && + (socks5Host || + (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) + ? { + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain: socks5ProxyChain as ProxyNode[], + } + : null; + + const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0; + + if (hasJumpHosts) { + try { + if (proxyConfig) { + connectionLogs.push( + createConnectionLog( + "info", + "proxy", + "Connecting via proxy + jump hosts", + ), + ); + } + connectionLogs.push( + createConnectionLog( + "info", + "jump", + `Connecting via ${host.jumpHosts!.length} jump host(s)`, + ), + ); + const jumpClient = await createJumpHostChain( + host.jumpHosts as Array<{ hostId: number }>, + userId, + proxyConfig, + ); + + if (!jumpClient) { + connectionLogs.push( + createConnectionLog( + "error", + "jump", + "Failed to establish jump host chain", + ), + ); + return res.status(500).json({ + error: "Failed to establish jump host chain", + connectionLogs, + }); + } + + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port || 22, + (err, stream) => { + if (err) { + sshLogger.error("Failed to forward through jump host", err, { + operation: "docker_jump_forward", + sessionId, + hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "jump", + `Failed to forward through jump host: ${err.message}`, + ), + ); + jumpClient.end(); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: + "Failed to forward through jump host: " + err.message, + connectionLogs, + }); + } + return; + } + + config.sock = stream; + client.connect(config); + }, + ); + } catch (jumpError) { + sshLogger.error("Jump host connection failed", jumpError, { + operation: "docker_jump_connect", + sessionId, + hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "jump", + `Jump host connection failed: ${jumpError instanceof Error ? jumpError.message : "Unknown error"}`, + ), + ); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: + "Jump host connection failed: " + + (jumpError instanceof Error + ? jumpError.message + : "Unknown error"), + connectionLogs, + }); + } + return; + } + } else if (proxyConfig) { + connectionLogs.push( + createConnectionLog("info", "proxy", "Connecting via proxy"), + ); + try { + const proxySocket = await createSocks5Connection( + host.ip, + host.port || 22, + proxyConfig, + ); + if (proxySocket) { + config.sock = proxySocket; + } + client.connect(config); + } catch (proxyError) { + sshLogger.error("Proxy connection failed", proxyError, { + operation: "docker_proxy_connect", + sessionId, + hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "proxy", + `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`, + ), + ); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: + "Proxy connection failed: " + + (proxyError instanceof Error + ? proxyError.message + : "Unknown error"), + connectionLogs, + }); + } + return; + } + } else { + await resolveSshConnectConfigHost(config); + client.connect(config); + } + } catch (error) { + sshLogger.error("Docker SSH connection error", error, { + operation: "docker_connect", + sessionId, + hostId, + userId, + }); + connectionLogs.push( + createConnectionLog( + "error", + "docker_connecting", + `Connection error: ${error instanceof Error ? error.message : "Unknown error"}`, + ), + ); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : "Unknown error", + connectionLogs, + }); + } + }); + + /** + * @openapi + * /docker/ssh/disconnect: + * post: + * summary: Disconnect SSH session + * description: Closes an active SSH session for Docker operations. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * responses: + * 200: + * description: SSH session disconnected. + * 400: + * description: Session ID is required. + */ + app.post("/docker/ssh/disconnect", async (req, res) => { + const { sessionId } = req.body; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + cleanupSession(sessionId); + + res.json({ success: true, message: "SSH session disconnected" }); + }); + + /** + * @openapi + * /docker/ssh/connect-totp: + * post: + * summary: Verify TOTP and complete connection + * description: Verifies the TOTP code and completes the SSH connection. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * totpCode: + * type: string + * responses: + * 200: + * description: TOTP verified, SSH connection established. + * 400: + * description: Session ID and TOTP code required. + * 401: + * description: Invalid TOTP code. + * 404: + * description: TOTP session expired. + */ + app.post("/docker/ssh/connect-totp", async (req, res) => { + const { sessionId, totpCode } = req.body; + const userId = getRequestUserId(req); + + if (!userId) { + sshLogger.error("TOTP verification rejected: no authenticated user", { + operation: "docker_totp_auth", + sessionId, + }); + return res.status(401).json({ error: "Authentication required" }); + } + + if (!sessionId || !totpCode) { + return res + .status(400) + .json({ error: "Session ID and TOTP code required" }); + } + + const session = pendingTOTPSessions[sessionId]; + + if (!session) { + sshLogger.warn("TOTP session not found or expired", { + operation: "docker_totp_verify", + sessionId, + userId, + availableSessions: Object.keys(pendingTOTPSessions), + }); + return res + .status(404) + .json({ error: "TOTP session expired. Please reconnect." }); + } + + if (Date.now() - session.createdAt > 180000) { + delete pendingTOTPSessions[sessionId]; + try { + session.client.end(); + } catch { + // expected + } + sshLogger.warn("TOTP session timeout before code submission", { + operation: "docker_totp_verify", + sessionId, + userId, + age: Date.now() - session.createdAt, + }); + return res + .status(408) + .json({ error: "TOTP session timeout. Please reconnect." }); + } + + const responses = (session.prompts || []).map((p, index) => { + if (index === session.totpPromptIndex) { + return totpCode; + } + if (/password/i.test(p.prompt) && session.resolvedPassword) { + return session.resolvedPassword; + } + return ""; + }); + + let responseSent = false; + + const responseTimeout = setTimeout(() => { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + sshLogger.warn("TOTP verification timeout", { + operation: "docker_totp_verify", + sessionId, + userId, + }); + res.status(408).json({ error: "TOTP verification timeout" }); + } + }, 60000); + + session.client.once("ready", () => { + if (responseSent) return; + responseSent = true; + clearTimeout(responseTimeout); + + delete pendingTOTPSessions[sessionId]; + + setTimeout(() => { + sshSessions[sessionId] = { + client: session.client, + isConnected: true, + lastActive: Date.now(), + activeOperations: 0, + hostId: session.hostId, + userId, + containerRuntime: session.containerRuntime, + }; + scheduleSessionCleanup(sessionId); + + res.json({ + status: "success", + message: "TOTP verified, SSH connection established", + }); + + if (session.hostId && session.userId) { + (async () => { + try { + const hostRow = + await createCurrentHostRepository().findByIdForUser( + session.userId!, + session.hostId!, + ); + + const hostName = + hostRow?.name || + `${session.username}@${session.ip}:${session.port}`; + + await axios.post( + "http://localhost:30006/activity/log", + { + type: "docker", + hostId: session.hostId, + hostName, + }, + { + headers: { + Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, + }, + }, + ); + } catch (error) { + sshLogger.warn("Failed to log Docker activity (TOTP)", { + operation: "activity_log_error", + userId: session.userId, + hostId: session.hostId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + })(); + } + }, 200); + }); + + session.client.once("error", (err) => { + if (responseSent) return; + responseSent = true; + clearTimeout(responseTimeout); + + delete pendingTOTPSessions[sessionId]; + + sshLogger.error("TOTP verification failed", { + operation: "docker_totp_verify", + sessionId, + userId, + error: err.message, + }); + + res.status(401).json({ status: "error", message: "Invalid TOTP code" }); + }); + + session.finish(responses); + }); + + /** + * @openapi + * /docker/ssh/connect-warpgate: + * post: + * summary: Complete Warpgate authentication + * description: Submits empty response to complete Warpgate authentication after user completes browser auth. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - sessionId + * properties: + * sessionId: + * type: string + * description: Session ID from initial connection attempt + * responses: + * 200: + * description: Warpgate authentication completed successfully. + * 401: + * description: Authentication failed or unauthorized. + * 404: + * description: Warpgate session expired. + */ + app.post("/docker/ssh/connect-warpgate", async (req, res) => { + const { sessionId } = req.body; + const userId = getRequestUserId(req); + + if (!userId) { + sshLogger.error("Warpgate verification rejected: no authenticated user", { + operation: "docker_warpgate_auth", + sessionId, + }); + return res.status(401).json({ error: "Authentication required" }); + } + + if (!sessionId) { + return res.status(400).json({ error: "Session ID required" }); + } + + const session = pendingTOTPSessions[sessionId]; + + if (!session) { + sshLogger.warn("Warpgate session not found or expired", { + operation: "docker_warpgate_verify", + sessionId, + userId, + availableSessions: Object.keys(pendingTOTPSessions), + }); + return res + .status(404) + .json({ error: "Warpgate session expired. Please reconnect." }); + } + + if (!session.isWarpgate) { + return res + .status(400) + .json({ error: "Session is not a Warpgate session" }); + } + + if (Date.now() - session.createdAt > 300000) { + delete pendingTOTPSessions[sessionId]; + try { + session.client.end(); + } catch { + // expected + } + sshLogger.warn("Warpgate session timeout before completion", { + operation: "docker_warpgate_verify", + sessionId, + userId, + age: Date.now() - session.createdAt, + }); + return res + .status(408) + .json({ error: "Warpgate session timeout. Please reconnect." }); + } + + let responseSent = false; + + const responseTimeout = setTimeout(() => { + if (!responseSent) { + responseSent = true; + delete pendingTOTPSessions[sessionId]; + sshLogger.warn("Warpgate verification timeout", { + operation: "docker_warpgate_verify", + sessionId, + userId, + }); + res.status(408).json({ error: "Warpgate verification timeout" }); + } + }, 60000); + + session.client.once("ready", () => { + if (responseSent) return; + responseSent = true; + clearTimeout(responseTimeout); + + delete pendingTOTPSessions[sessionId]; + + setTimeout(() => { + sshSessions[sessionId] = { + client: session.client, + isConnected: true, + lastActive: Date.now(), + activeOperations: 0, + hostId: session.hostId, + userId, + containerRuntime: session.containerRuntime, + }; + scheduleSessionCleanup(sessionId); + + res.json({ + status: "success", + message: "Warpgate verified, SSH connection established", + }); + + if (session.hostId && session.userId) { + (async () => { + try { + const hostRow = + await createCurrentHostRepository().findByIdForUser( + session.userId!, + session.hostId!, + ); + + const hostName = + hostRow?.name || + `${session.username}@${session.ip}:${session.port}`; + + await axios.post( + "http://localhost:30006/activity/log", + { + type: "docker", + hostId: session.hostId, + hostName, + }, + { + headers: { + Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, + }, + }, + ); + } catch (error) { + sshLogger.warn("Failed to log Docker activity (Warpgate)", { + operation: "activity_log_error", + userId: session.userId, + hostId: session.hostId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + })(); + } + }, 200); + }); + + session.client.once("error", (err) => { + if (responseSent) return; + responseSent = true; + clearTimeout(responseTimeout); + + delete pendingTOTPSessions[sessionId]; + + sshLogger.error("Warpgate verification failed", { + operation: "docker_warpgate_verify", + sessionId, + userId, + error: err.message, + }); + + res + .status(401) + .json({ status: "error", message: "Warpgate authentication failed" }); + }); + + session.finish([""]); + }); + + /** + * @openapi + * /docker/ssh/keepalive: + * post: + * summary: Keep SSH session alive + * description: Keeps an active SSH session alive. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * responses: + * 200: + * description: Session keepalive successful. + * 400: + * description: Session ID is required or session not found. + */ + app.post("/docker/ssh/keepalive", async (req, res) => { + const { sessionId } = req.body; + const userId = getRequestUserId(req); + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + connected: false, + }); + } + + if (session.userId && session.userId !== userId) { + return res.status(403).json({ error: "Session access denied" }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + res.json({ + success: true, + connected: true, + message: "Session keepalive successful", + lastActive: session.lastActive, + }); + }); + + /** + * @openapi + * /docker/ssh/status: + * get: + * summary: Check SSH session status + * description: Checks the status of an active SSH session. + * tags: + * - Docker + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Session status. + * 400: + * description: Session ID is required. + */ + app.get("/docker/ssh/status", async (req, res) => { + const sessionId = req.query.sessionId as string; + const userId = getRequestUserId(req); + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + const isConnected = + session?.userId === userId && session.isConnected === true; + + res.json({ success: true, connected: isConnected }); + }); + + /** + * @openapi + * /docker/validate/{sessionId}: + * get: + * summary: Validate Docker availability + * description: Validates if Docker is available on the host. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Docker availability status. + * 400: + * description: SSH session not found or not connected. + * 500: + * description: Validation failed. + */ + app.get("/docker/validate/:sessionId", async (req, res) => { + const { sessionId } = req.params; + const userId = getRequestUserId(req); + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (pendingTOTPSessions[sessionId]) { + return res.status(400).json({ + error: "Connection pending authentication", + code: "AUTH_PENDING", + }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + if (session.userId !== userId) { + return res.status(403).json({ error: "Session access denied" }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + try { + const runtime = session.containerRuntime ?? "docker"; + const runtimeLabel = getRuntimeLabel(runtime); + const versionOutput = await executeDockerCommand( + session, + containerCommand(runtime, "--version"), + sessionId, + userId, + session.hostId, + ); + const versionMatch = versionOutput.match( + /(?:Docker|podman) version ([^\s,]+)/i, + ); + const version = versionMatch ? versionMatch[1] : "unknown"; + + try { + await executeDockerCommand( + session, + containerCommand(runtime, "ps"), + sessionId, + userId, + session.hostId, + ); + + session.activeOperations--; + return res.json({ + available: true, + version, + runtime, + }); + } catch (daemonError) { + session.activeOperations--; + const errorMsg = + daemonError instanceof Error ? daemonError.message : ""; + + if (errorMsg.includes("Cannot connect to the Docker daemon")) { + return res.json({ + available: false, + error: `${runtimeLabel} daemon is not running or accessible`, + code: "DAEMON_NOT_RUNNING", + runtime, + }); + } + + if (errorMsg.includes("permission denied")) { + return res.json({ + available: false, + error: `Permission denied accessing ${runtimeLabel}`, + code: "PERMISSION_DENIED", + runtime, + }); + } + + return res.json({ + available: false, + error: errorMsg, + code: "DOCKER_ERROR", + runtime, + }); + } + } catch { + session.activeOperations--; + const runtime = session.containerRuntime ?? "docker"; + const runtimeLabel = getRuntimeLabel(runtime); + return res.json({ + available: false, + error: `${runtimeLabel} is not installed on this host.`, + code: "NOT_INSTALLED", + runtime, + }); + } + } catch (error) { + session.activeOperations--; + sshLogger.error("Docker validation error", error, { + operation: "docker_validate", + sessionId, + userId, + }); + + res.status(500).json({ + available: false, + error: error instanceof Error ? error.message : "Validation failed", + }); + } + }); +} diff --git a/src/backend/hosts/docker/session-manager.ts b/src/backend/hosts/docker/session-manager.ts new file mode 100644 index 00000000..6a620db1 --- /dev/null +++ b/src/backend/hosts/docker/session-manager.ts @@ -0,0 +1,171 @@ +import { Client as SSHClient } from "ssh2"; +import { logger } from "../../utils/logger.js"; +import type { ContainerRuntime } from "./container-runtime.js"; + +const sshLogger = logger; + +export interface SSHSession { + client: SSHClient; + isConnected: boolean; + lastActive: number; + timeout?: NodeJS.Timeout; + activeOperations: number; + hostId?: number; + userId?: string; + isWindows?: boolean; + containerRuntime?: ContainerRuntime; +} + +export interface PendingTOTPSession { + client: SSHClient; + finish: (responses: string[]) => void; + config: Record; + createdAt: number; + sessionId: string; + hostId?: number; + ip?: string; + port?: number; + username?: string; + userId?: string; + prompts?: Array<{ prompt: string; echo: boolean }>; + totpPromptIndex?: number; + resolvedPassword?: string; + totpAttempts: number; + isWarpgate?: boolean; + containerRuntime?: ContainerRuntime; +} + +export const sshSessions: Record = {}; +export const pendingTOTPSessions: Record = {}; + +const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000; + +setInterval(() => { + const now = Date.now(); + Object.keys(pendingTOTPSessions).forEach((sessionId) => { + const session = pendingTOTPSessions[sessionId]; + if (now - session.createdAt > 180000) { + try { + session.client.end(); + } catch { + // expected + } + delete pendingTOTPSessions[sessionId]; + } + }); +}, 60000); + +export function cleanupSession(sessionId: string) { + const session = sshSessions[sessionId]; + if (session) { + if (session.activeOperations > 0) { + sshLogger.warn( + `Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`, + { + operation: "cleanup_deferred", + sessionId, + activeOperations: session.activeOperations, + }, + ); + scheduleSessionCleanup(sessionId); + return; + } + + try { + session.client.end(); + } catch { + // expected + } + clearTimeout(session.timeout); + delete sshSessions[sessionId]; + } +} + +export function scheduleSessionCleanup(sessionId: string) { + const session = sshSessions[sessionId]; + if (session) { + if (session.timeout) clearTimeout(session.timeout); + + session.timeout = setTimeout(() => { + cleanupSession(sessionId); + }, SESSION_IDLE_TIMEOUT); + } +} + +export async function executeDockerCommand( + session: SSHSession, + command: string, + sessionId?: string, + userId?: string, + hostId?: number, +): Promise { + const startTime = Date.now(); + sshLogger.info("Executing Docker command", { + operation: "docker_command_exec", + sessionId, + userId, + hostId, + command: command.split(" ")[1], + }); + return new Promise((resolve, reject) => { + session.client.exec(command, (err, stream) => { + if (err) { + sshLogger.error("Docker command execution error", err, { + operation: "execute_docker_command", + sessionId, + userId, + hostId, + command: command.split(" ")[1], + }); + return reject(err); + } + + let stdout = ""; + let stderr = ""; + + stream.on("close", (code: number) => { + if (code !== 0) { + sshLogger.error("Docker command failed", undefined, { + operation: "execute_docker_command", + sessionId, + userId, + hostId, + command: command.split(" ")[1], + exitCode: code, + stderr, + }); + reject(new Error(stderr || `Command exited with code ${code}`)); + } else { + sshLogger.success("Docker command completed", { + operation: "docker_command_success", + sessionId, + userId, + hostId, + command: command.split(" ")[1], + duration: Date.now() - startTime, + }); + resolve(stdout); + } + }); + + stream.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + stream.on("error", (streamErr: Error) => { + sshLogger.error("Docker command stream error", streamErr, { + operation: "execute_docker_command", + sessionId, + userId, + hostId, + command: command.split(" ")[1], + }); + reject(streamErr); + }); + }); + }); +} diff --git a/src/backend/ssh/file-manager-action-routes.ts b/src/backend/hosts/file-manager/action-routes.ts similarity index 98% rename from src/backend/ssh/file-manager-action-routes.ts rename to src/backend/hosts/file-manager/action-routes.ts index 18a610cb..930aa31c 100644 --- a/src/backend/ssh/file-manager-action-routes.ts +++ b/src/backend/hosts/file-manager/action-routes.ts @@ -1,7 +1,7 @@ import type { Express } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { fileLogger } from "../utils/logger.js"; -import { execChannel, type SSHSession } from "./file-manager-session.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { fileLogger } from "../../utils/logger.js"; +import { execChannel, type SSHSession } from "./session.js"; type FileActionRoutesDeps = { sshSessions: Record; diff --git a/src/backend/ssh/file-manager-content-routes.ts b/src/backend/hosts/file-manager/content-routes.ts similarity index 85% rename from src/backend/ssh/file-manager-content-routes.ts rename to src/backend/hosts/file-manager/content-routes.ts index 853c17d5..bda93e68 100644 --- a/src/backend/ssh/file-manager-content-routes.ts +++ b/src/backend/hosts/file-manager/content-routes.ts @@ -1,21 +1,35 @@ import type { Express, Request, Response } from "express"; import Busboy from "busboy"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { fileLogger } from "../utils/logger.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { fileLogger } from "../../utils/logger.js"; import { execChannel, execWithSudo, execWithSudoBuffer, getSessionSftp, type SSHSession, -} from "./file-manager-session.js"; -import { detectBinary } from "./file-manager-utils.js"; +} from "./session.js"; +import { detectBinary } from "./utils.js"; type FileContentRoutesDeps = { sshSessions: Record; verifySessionOwnership: (session: SSHSession, userId: string) => boolean; }; +const getRequiredQueryParam = ( + value: string | string[] | undefined, +): string | undefined => { + if (Array.isArray(value)) return value[0]; + return value; +}; + +const parseByteOffset = (value: string | undefined): number | null => { + if (!value) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) return null; + return parsed; +}; + export function registerFileContentRoutes( app: Express, { sshSessions, verifySessionOwnership }: FileContentRoutesDeps, @@ -1512,264 +1526,125 @@ export function registerFileContentRoutes( * @openapi * /ssh/file_manager/ssh/uploadFileChunk: * post: - * summary: Upload a single chunk of a large file - * description: Receives one chunk of a large file upload via multipart form data and either creates a new remote file (chunkIndex=0) or appends to the existing file (chunkIndex>0). Used by the client to upload files larger than ~2GB that would otherwise hit browser-side ArrayBuffer limits. + * summary: Upload one raw file chunk + * description: Writes a raw request body to the remote file at the supplied byte offset, allowing browser clients to avoid multipart/FormData 2GB limits. * tags: * - File Manager - * requestBody: - * required: true - * content: - * multipart/form-data: - * schema: - * type: object - * required: - * - sessionId - * - path - * - fileName - * - chunkIndex - * - totalChunks - * - totalSize - * - chunk - * properties: - * sessionId: - * type: string - * path: - * type: string - * fileName: - * type: string - * chunkIndex: - * type: integer - * totalChunks: - * type: integer - * totalSize: - * type: integer - * chunk: - * type: string - * format: binary - * responses: - * 200: - * description: Chunk accepted. When chunkIndex === totalChunks - 1 the full file is complete. - * 400: - * description: Missing required parameters, invalid chunk metadata, or SSH connection not established. - * 403: - * description: Session access denied. - * 500: - * description: Failed to write chunk to remote filesystem. */ app.post( "/ssh/file_manager/ssh/uploadFileChunk", (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - - const contentType = req.headers["content-type"] || ""; - if (!contentType.includes("multipart/form-data")) { - return res - .status(400) - .json({ error: "Expected multipart/form-data request" }); - } - - let sessionId: string | undefined; - let remotePath: string | undefined; - let fileName: string | undefined; - let chunkIndex = -1; - let totalChunks = 0; - let totalSize = 0; - let resolved = false; - let chunkStartTime = Date.now(); - - const bb = Busboy({ - headers: req.headers, - limits: { - fileSize: 32 * 1024 * 1024, - }, - }); - - bb.on("field", (name: string, value: string) => { - if (name === "sessionId") sessionId = value; - else if (name === "path") remotePath = value; - else if (name === "fileName") fileName = value; - else if (name === "chunkIndex") { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n >= 0) chunkIndex = n; - } else if (name === "totalChunks") { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n > 0) totalChunks = n; - } else if (name === "totalSize") { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n >= 0) totalSize = n; - } - }); - - bb.on( - "file", - ( - fieldname: string, - fileStream: NodeJS.ReadableStream, - _info: { filename: string; encoding: string; mimeType: string }, - ) => { - if ( - !sessionId || - !remotePath || - !fileName || - chunkIndex < 0 || - totalChunks <= 0 || - chunkIndex >= totalChunks - ) { - fileStream.resume(); - if (!resolved) { - resolved = true; - res.status(400).json({ - error: - "Missing or invalid chunk metadata (sessionId, path, fileName, chunkIndex, totalChunks required)", - }); - } - return; - } - - const sshConn = sshSessions[sessionId]; - if (!sshConn?.isConnected) { - fileStream.resume(); - if (!resolved) { - resolved = true; - res.status(400).json({ error: "SSH connection not established" }); - } - return; - } - - if (!verifySessionOwnership(sshConn, userId)) { - fileStream.resume(); - if (!resolved) { - resolved = true; - res.status(403).json({ error: "Session access denied" }); - } - return; - } - - sshConn.lastActive = Date.now(); - chunkStartTime = Date.now(); - - const fullPath = remotePath.endsWith("/") - ? remotePath + fileName - : remotePath + "/" + fileName; - - const flags = chunkIndex === 0 ? "w" : "a"; - - fileLogger.info("Chunk upload started", { - operation: "file_upload_chunk_start", - sessionId, - userId, - path: fullPath, - chunkIndex, - totalChunks, - totalSize, - }); - - getSessionSftp(sshConn) - .then((sftp) => { - const writeStream = sftp.createWriteStream(fullPath, { - flags, - }); - - let chunkBytes = 0; - - fileStream.on("data", (data: Buffer) => { - chunkBytes += data.length; - }); - - writeStream.on("error", (err) => { - fileLogger.error( - "SFTP write stream error during chunk upload:", - err, - ); - fileStream.resume(); - if (!resolved) { - resolved = true; - res - .status(500) - .json({ error: `Chunk upload failed: ${err.message}` }); - } - }); - - writeStream.on("finish", () => { - if (resolved) return; - resolved = true; - const isFinalChunk = chunkIndex === totalChunks - 1; - fileLogger.success( - isFinalChunk - ? "Chunked file upload completed" - : "Chunk upload completed", - { - operation: isFinalChunk - ? "file_upload_chunked_complete" - : "file_upload_chunk_complete", - sessionId, - userId, - path: fullPath, - chunkIndex, - totalChunks, - chunkBytes, - totalSize, - duration: Date.now() - chunkStartTime, - }, - ); - res.json({ - message: isFinalChunk - ? "File uploaded successfully" - : "Chunk accepted", - path: fullPath, - chunkIndex, - totalChunks, - chunkBytes, - complete: isFinalChunk, - toast: isFinalChunk - ? { - type: "success", - message: `File uploaded: ${fullPath}`, - } - : undefined, - }); - }); - - fileStream.on("error", (err) => { - fileLogger.error( - "File read stream error during chunk upload:", - err, - ); - writeStream.destroy(); - if (!resolved) { - resolved = true; - res.status(500).json({ - error: `Chunk upload stream error: ${err.message}`, - }); - } - }); - - (fileStream as NodeJS.ReadableStream).pipe( - writeStream as unknown as NodeJS.WritableStream, - ); - }) - .catch((err: Error) => { - fileStream.resume(); - fileLogger.error("SFTP session error during chunk upload:", err); - if (!resolved) { - resolved = true; - res.status(500).json({ error: `SFTP error: ${err.message}` }); - } - }); - }, + const sessionId = getRequiredQueryParam(req.query.sessionId as string); + const remotePath = getRequiredQueryParam(req.query.path as string); + const fileName = getRequiredQueryParam(req.query.fileName as string); + const offset = parseByteOffset( + getRequiredQueryParam(req.query.offset as string), + ); + const totalSize = parseByteOffset( + getRequiredQueryParam(req.query.totalSize as string), ); - bb.on("error", (err: Error) => { - fileLogger.error("Busboy parse error during chunk upload:", err); - if (!resolved) { - resolved = true; - res - .status(500) - .json({ error: `Chunk upload parse error: ${err.message}` }); - } - }); + if (!sessionId || !remotePath || !fileName) { + req.resume(); + return res + .status(400) + .json({ error: "Missing sessionId, path, or fileName" }); + } - req.pipe(bb); + if (offset === null || totalSize === null || offset > totalSize) { + req.resume(); + return res.status(400).json({ error: "Invalid upload offset" }); + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn?.isConnected) { + req.resume(); + return res + .status(400) + .json({ error: "SSH connection not established" }); + } + + if (!verifySessionOwnership(sshConn, userId)) { + req.resume(); + return res.status(403).json({ error: "Session access denied" }); + } + + sshConn.lastActive = Date.now(); + const fullPath = remotePath.endsWith("/") + ? remotePath + fileName + : remotePath + "/" + fileName; + let resolved = false; + let bytesWritten = 0; + const uploadStartTime = Date.now(); + + getSessionSftp(sshConn) + .then((sftp) => { + const writeStream = sftp.createWriteStream(fullPath, { + flags: offset === 0 ? "w" : "r+", + start: offset, + }); + + const fail = (status: number, error: string) => { + if (resolved) return; + resolved = true; + req.unpipe(writeStream as unknown as NodeJS.WritableStream); + writeStream.destroy(); + res.status(status).json({ error }); + }; + + writeStream.on("error", (err) => { + fileLogger.error( + "SFTP write stream error during chunk upload:", + err, + ); + fail(500, `Upload failed: ${err.message}`); + }); + + writeStream.on("finish", () => { + if (resolved) return; + resolved = true; + const nextOffset = offset + bytesWritten; + fileLogger.info("File chunk upload completed", { + operation: "file_upload_chunk_complete", + sessionId, + userId, + path: fullPath, + offset, + bytesWritten, + nextOffset, + totalSize, + duration: Date.now() - uploadStartTime, + }); + res.json({ + message: "File chunk uploaded successfully", + path: fullPath, + offset, + bytesWritten, + nextOffset, + complete: nextOffset >= totalSize, + }); + }); + + req.on("error", (err) => { + fileLogger.error("Request stream error during chunk upload:", err); + fail(500, `Upload stream error: ${err.message}`); + }); + + req.on("data", (chunk: Buffer) => { + bytesWritten += chunk.length; + }); + + req.pipe(writeStream as unknown as NodeJS.WritableStream); + }) + .catch((err: Error) => { + req.resume(); + fileLogger.error("SFTP session error during chunk upload:", err); + if (!resolved) { + resolved = true; + res.status(500).json({ error: `SFTP error: ${err.message}` }); + } + }); }, ); } diff --git a/src/backend/ssh/file-manager-download-routes.ts b/src/backend/hosts/file-manager/download-routes.ts similarity index 97% rename from src/backend/ssh/file-manager-download-routes.ts rename to src/backend/hosts/file-manager/download-routes.ts index e7e58d60..99ac4361 100644 --- a/src/backend/ssh/file-manager-download-routes.ts +++ b/src/backend/hosts/file-manager/download-routes.ts @@ -1,12 +1,8 @@ import type { Express } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { fileLogger } from "../utils/logger.js"; -import { getMimeType } from "./file-manager-utils.js"; -import { - execChannel, - getSessionSftp, - type SSHSession, -} from "./file-manager-session.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { fileLogger } from "../../utils/logger.js"; +import { getMimeType } from "./utils.js"; +import { execChannel, getSessionSftp, type SSHSession } from "./session.js"; type FileDownloadRoutesDeps = { sshSessions: Record; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/hosts/file-manager/index.ts similarity index 96% rename from src/backend/ssh/file-manager.ts rename to src/backend/hosts/file-manager/index.ts index 1a799d9b..5f378a64 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -1,24 +1,24 @@ import express from "express"; -import { createCorsMiddleware } from "../utils/cors-config.js"; +import { createCorsMiddleware } from "../../utils/cors-config.js"; import cookieParser from "cookie-parser"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { getDb } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { fileLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; +import { fileLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { resolveHostById } from "./host-resolver.js"; -import type { SSHHost } from "../../types/index.js"; +} from "../../utils/socks5-helper.js"; +import type { + LogEntry, + ConnectionStage, +} from "../../../types/connection-log.js"; +import { SSHHostKeyVerifier } from "../host-key-verifier.js"; +import { resolveHostById } from "../host-resolver.js"; +import type { SSHHost } from "../../../types/index.js"; import { startHostTransfer, getTransferStatus, @@ -29,23 +29,24 @@ import { retryHostTransfer, previewArchiveTransferMethod, type HostTransferDeps, -} from "./host-transfer.js"; -import { registerFileContentRoutes } from "./file-manager-content-routes.js"; -import { createConnectionLog } from "./file-manager-log.js"; -import { createJumpHostChain } from "./jump-host-chain.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; +} from "./transfer-engine.js"; +import { registerFileContentRoutes } from "./content-routes.js"; +import { createConnectionLog } from "../connection-log.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { ChannelOpenSerializer, execChannel, getSessionSftp, type PendingTOTPSession, type SSHSession, -} from "./file-manager-session.js"; -import { registerFileListingRoutes } from "./file-manager-list-routes.js"; -import { registerFileOperationRoutes } from "./file-manager-operation-routes.js"; -import { registerFileDownloadRoutes } from "./file-manager-download-routes.js"; -import { registerFileActionRoutes } from "./file-manager-action-routes.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; +} from "./session.js"; +import { registerFileListingRoutes } from "./list-routes.js"; +import { registerFileOperationRoutes } from "./operation-routes.js"; +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { registerFileDownloadRoutes } from "./download-routes.js"; +import { registerFileActionRoutes } from "./action-routes.js"; +import { applyAgentAuth } from "../terminal-auth-helpers.js"; const app = express(); @@ -194,6 +195,7 @@ async function buildDedicatedTransferConnectConfig( compress: ["none", "zlib@openssh.com", "zlib"], }, }; + await resolveSshConnectConfigHost(config); const authType = host.authType; @@ -210,14 +212,14 @@ async function buildDedicatedTransferConnectConfig( } config.password = host.password; } else if (authType === "opkssh") { - const { getOPKSSHToken } = await import("./opkssh-auth.js"); + const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, host.id); if (!token) { throw new Error( "OPKSSH authentication required. Open a Terminal connection to this host first.", ); } - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth( config as import("ssh2").ConnectConfig, client, @@ -748,7 +750,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { let resolvedSocks5ProxyChain = socks5ProxyChain; if (hostId && userId && !password && !sshKey) { try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById(hostId, userId); if (resolvedHost) { resolvedIp = resolvedHost.ip; @@ -807,7 +809,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } else if (credentialId && hostId && userId) { // Legacy: credential resolution from credentialId try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById(hostId, userId); if (resolvedHost) { resolvedIp = resolvedHost.ip; @@ -999,7 +1001,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ); } else if (resolvedCredentials.authType === "opkssh") { try { - const { getOPKSSHToken } = await import("./opkssh-auth.js"); + const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); if (!token) { @@ -1018,7 +1020,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }); } - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth( config as import("ssh2").ConnectConfig, client, @@ -1176,18 +1178,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { if (hostId && userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === userId && host.name + ? host.name : `${username}@${ip}:${port}`; const authManager = AuthManager.getInstance(); @@ -1759,6 +1758,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }); } } else { + await resolveSshConnectConfigHost(config); client.connect(config); } }); @@ -1874,23 +1874,15 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { if (session.hostId && session.userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, session.hostId!), - eq(hosts.userId, session.userId!), - ), - ), - "ssh_data", - session.userId!, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + session.hostId!, + session.userId!, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === session.userId && host.name + ? host.name : `${session.username}@${session.ip}:${session.port}`; const authManager = AuthManager.getInstance(); @@ -2081,23 +2073,15 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => { if (session.hostId && session.userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, session.hostId!), - eq(hosts.userId, session.userId!), - ), - ), - "ssh_data", - session.userId!, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + session.hostId!, + session.userId!, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === session.userId && host.name + ? host.name : `${session.username}@${session.ip}:${session.port}`; await axios.post( diff --git a/src/backend/ssh/file-manager-list-routes.ts b/src/backend/hosts/file-manager/list-routes.ts similarity index 98% rename from src/backend/ssh/file-manager-list-routes.ts rename to src/backend/hosts/file-manager/list-routes.ts index a279a63c..a4b1a343 100644 --- a/src/backend/ssh/file-manager-list-routes.ts +++ b/src/backend/hosts/file-manager/list-routes.ts @@ -1,17 +1,13 @@ import type { Express } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { fileLogger } from "../utils/logger.js"; -import { - execChannel, - getSessionSftp, - type SSHSession, -} from "./file-manager-session.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { fileLogger } from "../../utils/logger.js"; +import { execChannel, getSessionSftp, type SSHSession } from "./session.js"; import { formatMtime, isExecutableFile, modeToPermissions, parseLsDateToTimestamp, -} from "./file-manager-utils.js"; +} from "./utils.js"; type FileListingRoutesDeps = { sshSessions: Record; diff --git a/src/backend/hosts/file-manager/operation-commands.ts b/src/backend/hosts/file-manager/operation-commands.ts new file mode 100644 index 00000000..2aad2c30 --- /dev/null +++ b/src/backend/hosts/file-manager/operation-commands.ts @@ -0,0 +1,39 @@ +import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js"; + +export interface DeleteCommand { + command: string; + commandWithSuccess: string; +} + +function quotePosixPath(path: string): string { + return `'${path.replace(/'/g, "'\"'\"'")}'`; +} + +function quotePowerShellLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +export function buildDeleteCommand( + itemPath: string, + isDirectory: boolean, +): DeleteCommand { + if (isWindowsSftpPath(itemPath)) { + const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath)); + const command = isDirectory + ? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop` + : `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`; + + return { + command, + commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`, + }; + } + + const path = quotePosixPath(itemPath); + const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`; + + return { + command, + commandWithSuccess: `${command} && echo "SUCCESS"`, + }; +} diff --git a/src/backend/ssh/file-manager-operation-routes.ts b/src/backend/hosts/file-manager/operation-routes.ts similarity index 85% rename from src/backend/ssh/file-manager-operation-routes.ts rename to src/backend/hosts/file-manager/operation-routes.ts index 3cbf25ee..4a2e4977 100644 --- a/src/backend/ssh/file-manager-operation-routes.ts +++ b/src/backend/hosts/file-manager/operation-routes.ts @@ -1,12 +1,8 @@ import type { Express } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { fileLogger } from "../utils/logger.js"; -import { - execChannel, - execWithSudo, - type SSHSession, -} from "./file-manager-session.js"; -import { isWindowsSftpPath } from "./transfer-paths.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { fileLogger } from "../../utils/logger.js"; +import { execChannel, execWithSudo, type SSHSession } from "./session.js"; +import { buildDeleteCommand } from "./operation-commands.js"; type FileOperationRoutesDeps = { sshSessions: Record; @@ -375,22 +371,10 @@ export function registerFileOperationRoutes( }); sshConn.lastActive = Date.now(); - const isWindowsPath = isWindowsSftpPath(itemPath); - let deleteCommand: string; - if (isWindowsPath) { - const winPath = itemPath - .replace(/\//g, "\\") - .replace(/^\\([A-Za-z]:\\)/, "$1"); - const escapedWinPath = winPath.replace(/"/g, '""'); - deleteCommand = isDirectory - ? `rd /s /q "${escapedWinPath}"` - : `del /f /q "${escapedWinPath}"`; - } else { - const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); - deleteCommand = isDirectory - ? `rm -rf '${escapedPath}'` - : `rm -f '${escapedPath}'`; - } + const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand( + itemPath, + Boolean(isDirectory), + ); const executeDelete = (useSudo: boolean): Promise => { return new Promise((resolve) => { @@ -421,89 +405,85 @@ export function registerFileOperationRoutes( return; } - execChannel( - sshConn, - `${deleteCommand} && echo "SUCCESS"`, - (err, stream) => { - if (err) { - fileLogger.error("SSH deleteItem error:", err); - res.status(500).json({ error: err.message }); + execChannel(sshConn, commandWithSuccess, (err, stream) => { + if (err) { + fileLogger.error("SSH deleteItem error:", err); + res.status(500).json({ error: err.message }); + resolve(); + return; + } + + let outputData = ""; + let errorData = ""; + let permissionDenied = false; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + if (chunk.toString().includes("Permission denied")) { + permissionDenied = true; + } + }); + + stream.on("close", (code) => { + if (permissionDenied) { + if (sshConn.sudoPassword) { + executeDelete(true).then(resolve); + return; + } + fileLogger.error(`Permission denied deleting: ${itemPath}`); + res.status(403).json({ + error: `Permission denied: Cannot delete ${itemPath}.`, + needsSudo: true, + }); resolve(); return; } - let outputData = ""; - let errorData = ""; - let permissionDenied = false; + if (outputData.includes("SUCCESS")) { + fileLogger.success("Item deleted successfully", { + operation: "file_delete_success", + sessionId, + userId, + path: itemPath, + }); + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } else { + const detail = + errorData.trim() || + outputData.trim() || + `command exited with code ${code} and produced no output (the remote shell may not support the delete command)`; + fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, { + operation: "file_delete_failed", + sessionId, + userId, + path: itemPath, + }); + res.status(500).json({ + error: `Delete failed: ${detail}`, + }); + } + resolve(); + }); - stream.on("data", (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - if (chunk.toString().includes("Permission denied")) { - permissionDenied = true; - } - }); - - stream.on("close", (code) => { - if (permissionDenied) { - if (sshConn.sudoPassword) { - executeDelete(true).then(resolve); - return; - } - fileLogger.error(`Permission denied deleting: ${itemPath}`); - res.status(403).json({ - error: `Permission denied: Cannot delete ${itemPath}.`, - needsSudo: true, - }); - resolve(); - return; - } - - if (outputData.includes("SUCCESS")) { - fileLogger.success("Item deleted successfully", { - operation: "file_delete_success", - sessionId, - userId, - path: itemPath, - }); - res.json({ - message: "Item deleted successfully", - path: itemPath, - toast: { - type: "success", - message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, - }, - }); - } else { - const detail = - errorData.trim() || - outputData.trim() || - `command exited with code ${code} and produced no output (the remote shell may not support the delete command)`; - fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, { - operation: "file_delete_failed", - sessionId, - userId, - path: itemPath, - }); - res.status(500).json({ - error: `Delete failed: ${detail}`, - }); - } - resolve(); - }); - - stream.on("error", (streamErr) => { - fileLogger.error("SSH deleteItem stream error:", streamErr); - res - .status(500) - .json({ error: `Stream error: ${streamErr.message}` }); - resolve(); - }); - }, - ); + stream.on("error", (streamErr) => { + fileLogger.error("SSH deleteItem stream error:", streamErr); + res + .status(500) + .json({ error: `Stream error: ${streamErr.message}` }); + resolve(); + }); + }); }); }; diff --git a/src/backend/ssh/file-manager-session.ts b/src/backend/hosts/file-manager/session.ts similarity index 100% rename from src/backend/ssh/file-manager-session.ts rename to src/backend/hosts/file-manager/session.ts diff --git a/src/backend/ssh/host-transfer.ts b/src/backend/hosts/file-manager/transfer-engine.ts similarity index 99% rename from src/backend/ssh/host-transfer.ts rename to src/backend/hosts/file-manager/transfer-engine.ts index 7d12eeff..4a83a280 100644 --- a/src/backend/ssh/host-transfer.ts +++ b/src/backend/hosts/file-manager/transfer-engine.ts @@ -2,7 +2,7 @@ import { randomUUID } from "crypto"; import { networkInterfaces } from "os"; import { performance } from "node:perf_hooks"; import type { ClientChannel } from "ssh2"; -import { fileLogger } from "../utils/logger.js"; +import { fileLogger } from "../../utils/logger.js"; import { basename, buildPathFromSegments, @@ -15,7 +15,7 @@ import { sftpPathToLocalPath, splitPathSegments, type TransferPlatform, -} from "./transfer-paths.js"; +} from "../transfer-paths.js"; import { buildTransferScanSummary, getArchiveTransferReasonKey, diff --git a/src/backend/ssh/transfer-routing.ts b/src/backend/hosts/file-manager/transfer-routing.ts similarity index 98% rename from src/backend/ssh/transfer-routing.ts rename to src/backend/hosts/file-manager/transfer-routing.ts index eab1456c..c351742f 100644 --- a/src/backend/ssh/transfer-routing.ts +++ b/src/backend/hosts/file-manager/transfer-routing.ts @@ -1,4 +1,4 @@ -import type { TransferPlatform } from "./transfer-paths.js"; +import type { TransferPlatform } from "../transfer-paths.js"; export type TransferMethodPreference = "auto" | "tar" | "item_sftp"; diff --git a/src/backend/ssh/file-manager-utils.ts b/src/backend/hosts/file-manager/utils.ts similarity index 100% rename from src/backend/ssh/file-manager-utils.ts rename to src/backend/hosts/file-manager/utils.ts diff --git a/src/backend/hosts/guacamole/guacamole-server.ts b/src/backend/hosts/guacamole/guacamole-server.ts new file mode 100644 index 00000000..1d142e21 --- /dev/null +++ b/src/backend/hosts/guacamole/guacamole-server.ts @@ -0,0 +1,190 @@ +import GuacamoleLite from "guacamole-lite"; +import { guacLogger } from "../../utils/logger.js"; +import { GuacamoleTokenService } from "./token-service.js"; +import { getCurrentSettingValue } from "../../database/repositories/factory.js"; +import { resolveGuacdOptions } from "../../utils/guacd-config.js"; +import fs from "fs"; +import path from "path"; +import { createCurrentSessionRecordingRepository } from "../../database/repositories/factory.js"; +import type { GuacamoleRecordingMetadata } from "./token-service.js"; + +const tokenService = GuacamoleTokenService.getInstance(); + +function readGuacdOptions(): { host: string; port: number } { + let dbUrl: string | undefined; + try { + dbUrl = getCurrentSettingValue("guac_url") ?? undefined; + } catch { + // DB not available yet, use env var defaults + } + return resolveGuacdOptions(dbUrl); +} + +const GUAC_WS_PORT = 30008; +const DATA_DIR = process.env.DATA_DIR || "./db/data"; +const GUACAMOLE_RECORDINGS_DIR = + process.env.GUACD_RECORDING_BACKEND_PATH || + path.join(DATA_DIR, "session_recordings", "guacamole"); + +type GuacamoleClientConnection = { + connectionSettings?: { + connection?: { type?: string }; + recording?: GuacamoleRecordingMetadata; + }; +}; + +async function persistGuacamoleRecording( + clientConnection: GuacamoleClientConnection, +): Promise { + const recording = clientConnection.connectionSettings?.recording; + if (!recording) return; + + const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path); + const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`; + if (!resolvedPath.startsWith(allowedBase)) return; + + // guacd may flush/rename the recording just after the websocket closes. + for ( + let attempt = 0; + attempt < 10 && !fs.existsSync(resolvedPath); + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!fs.existsSync(resolvedPath)) { + guacLogger.warn("Guacamole recording file was not found", { + operation: "guac_recording_missing", + hostId: recording.hostId, + path: resolvedPath, + }); + return; + } + + const endedAt = new Date(); + const startedAt = new Date(recording.startedAt); + await createCurrentSessionRecordingRepository().create({ + hostId: recording.hostId, + userId: recording.userId, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + duration: Math.max( + 0, + Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000), + ), + recordingPath: resolvedPath, + protocol: recording.protocol, + format: "guacamole", + }); +} + +const websocketOptions = { + port: GUAC_WS_PORT, +}; + +const clientOptions = { + crypt: { + cypher: "AES-256-CBC", + key: tokenService.getEncryptionKey(), + }, + log: { + level: "ERRORS", + stdLog: (...args: unknown[]) => { + guacLogger.info(args.join(" ")); + }, + errorLog: (...args: unknown[]) => { + guacLogger.error(args.join(" ")); + }, + }, + allowedUnencryptedConnectionSettings: { + rdp: ["width", "height", "dpi"], + vnc: ["width", "height"], + telnet: ["width", "height"], + }, + connectionDefaultSettings: { + rdp: { + security: "any", + "ignore-cert": true, + "enable-wallpaper": false, + "enable-font-smoothing": true, + "enable-desktop-composition": false, + "disable-audio": false, + "enable-drive": false, + "resize-method": "display-update", + width: 1280, + height: 720, + dpi: 96, + audio: ["audio/L16"], + }, + vnc: { + "swap-red-blue": false, + cursor: "remote", + security: "any", + width: 1280, + height: 720, + }, + telnet: { + "terminal-type": "xterm-256color", + }, + }, +}; + +const _origConsoleLog = console.log; +console.log = (...args: unknown[]) => { + const msg = args[0]; + if (typeof msg === "string" && msg.startsWith("New client connection")) + return; + _origConsoleLog(...args); +}; + +function createGuacServer(): GuacamoleLite { + const guacdOptions = readGuacdOptions(); + const server = new GuacamoleLite( + websocketOptions, + guacdOptions, + clientOptions, + ); + + server.on("open", (clientConnection: GuacamoleClientConnection) => { + guacLogger.info("Guacamole connection opened", { + operation: "guac_connection_open", + type: clientConnection.connectionSettings?.connection?.type, + }); + }); + + server.on("close", (clientConnection: GuacamoleClientConnection) => { + guacLogger.info("Guacamole connection closed", { + operation: "guac_connection_close", + type: clientConnection.connectionSettings?.connection?.type, + }); + persistGuacamoleRecording(clientConnection).catch((error) => { + guacLogger.error("Failed to persist Guacamole recording", error, { + operation: "guac_recording_persist_error", + }); + }); + }); + + server.on( + "error", + (clientConnection: GuacamoleClientConnection, error: Error) => { + guacLogger.error("Guacamole connection error", error, { + operation: "guac_connection_error", + type: clientConnection.connectionSettings?.connection?.type, + }); + }, + ); + + return server; +} + +let guacServer = createGuacServer(); + +export async function restartGuacServer(): Promise { + try { + guacServer.close(); + } catch (err) { + guacLogger.error("Error closing guac server during restart", err as Error); + } + guacServer = createGuacServer(); +} + +export { guacServer, tokenService }; diff --git a/src/backend/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts similarity index 68% rename from src/backend/guacamole/routes.ts rename to src/backend/hosts/guacamole/routes.ts index 2e3e0da0..0e47c2f9 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -1,20 +1,23 @@ import express from "express"; import { GuacamoleTokenService } from "./token-service.js"; -import { guacLogger } from "../utils/logger.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { PermissionManager } from "../utils/permission-manager.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { guacLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; import { Client } from "ssh2"; import net from "net"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { resolveGuacdOptions } from "../utils/guacd-config.js"; +import crypto from "crypto"; +import path from "path"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { + createCurrentHostResolutionRepository, + createCurrentSettingsRepository, +} from "../../database/repositories/factory.js"; +import { resolveGuacdOptions } from "../../utils/guacd-config.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); const authManager = AuthManager.getInstance(); +const DATA_DIR = process.env.DATA_DIR || "./db/data"; router.use(authManager.createAuthMiddleware()); @@ -193,24 +196,24 @@ router.post( return res.status(400).json({ error: "Invalid host ID" }); } - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); + const hostResolutionRepository = createCurrentHostResolutionRepository(); + // Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields. + const hostOwnerId = + await hostResolutionRepository.findHostOwnerId(hostId); + const host = hostOwnerId + ? await hostResolutionRepository.findHostById(hostId, hostOwnerId) + : null; - if (hostResults.length === 0) { + if (!host) { return res.status(404).json({ error: "Host not found" }); } - const host = hostResults[0]; - if (host.userId !== userId) { const permissionManager = PermissionManager.getInstance(); const accessInfo = await permissionManager.canAccessHost( userId, hostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { @@ -294,109 +297,121 @@ router.post( } const hostRecord = host as Record; + const hostRepository = hostResolutionRepository; + const isSharedConnection = host.userId !== userId; - // Backward compat: if authType is not stored but a credentialId is, treat as credential mode - const rdpEffectiveAuthType = - (host.rdpAuthType as string) || - (host.rdpCredentialId ? "credential" : "direct"); - const vncEffectiveAuthType = - (host.vncAuthType as string) || - (host.vncCredentialId ? "credential" : "direct"); - const telnetEffectiveAuthType = - (host.telnetAuthType as string) || - (hostRecord.telnetCredentialId ? "credential" : "direct"); + if (isSharedConnection) { + // Recipients never read the owner's raw secrets; wipe them and use + // the per-recipient snapshot for the requested protocol instead. + host.password = null; + host.rdpUser = null; + host.rdpPassword = null; + host.vncUser = null; + host.vncPassword = null; + host.telnetUser = null; + host.telnetPassword = null; - if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) { try { - const rdpCreds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.rdpCredentialId as number), - eq(sshCredentials.userId, host.userId as string), - ), - ), - "ssh_credentials", - userId, - ); - if (rdpCreds.length > 0) { - const cred = rdpCreds[0] as Record; - if (cred.username) host.rdpUser = cred.username; - if (cred.password) host.rdpPassword = cred.password; - // domain is never sourced from credential + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + connectionType as "rdp" | "vnc" | "telnet", + ); + if (secret) { + if (connectionType === "rdp") { + host.rdpUser = secret.username ?? null; + host.rdpPassword = secret.password ?? null; + if (secret.domain) host.rdpDomain = secret.domain; + } else if (connectionType === "vnc") { + host.vncUser = secret.username ?? null; + host.vncPassword = secret.password ?? null; + } else if (connectionType === "telnet") { + host.telnetUser = secret.username ?? null; + host.telnetPassword = secret.password ?? null; + } } } catch (e) { - guacLogger.warn("Failed to resolve RDP credential", { - operation: "guac_rdp_credential_resolve", + guacLogger.warn("Failed to resolve shared host secret", { + operation: "guac_shared_secret_resolve", hostId, + protocol: connectionType, error: e instanceof Error ? e.message : "Unknown", }); } - } + } else { + // Backward compat: if authType is not stored but a credentialId is, treat as credential mode + const rdpEffectiveAuthType = + (host.rdpAuthType as string) || + (host.rdpCredentialId ? "credential" : "direct"); + const vncEffectiveAuthType = + (host.vncAuthType as string) || + (host.vncCredentialId ? "credential" : "direct"); + const telnetEffectiveAuthType = + (host.telnetAuthType as string) || + (hostRecord.telnetCredentialId ? "credential" : "direct"); - if (vncEffectiveAuthType === "credential" && host.vncCredentialId) { - try { - const vncCreds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.vncCredentialId as number), - eq(sshCredentials.userId, host.userId as string), - ), - ), - "ssh_credentials", - userId, - ); - if (vncCreds.length > 0) { - const cred = vncCreds[0] as Record; - if (cred.password) host.vncPassword = cred.password; - if (cred.username) host.vncUser = cred.username; + if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) { + try { + const cred = await hostRepository.findCredentialByIdForUser( + host.rdpCredentialId as number, + host.userId as string, + ); + if (cred) { + if (cred.username) host.rdpUser = cred.username; + if (cred.password) host.rdpPassword = cred.password; + // domain is never sourced from credential + } + } catch (e) { + guacLogger.warn("Failed to resolve RDP credential", { + operation: "guac_rdp_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); } - } catch (e) { - guacLogger.warn("Failed to resolve VNC credential", { - operation: "guac_vnc_credential_resolve", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); } - } - if ( - telnetEffectiveAuthType === "credential" && - hostRecord.telnetCredentialId - ) { - try { - const telnetCreds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq( - sshCredentials.id, - hostRecord.telnetCredentialId as number, - ), - eq(sshCredentials.userId, host.userId as string), - ), - ), - "ssh_credentials", - userId, - ); - if (telnetCreds.length > 0) { - const cred = telnetCreds[0] as Record; - if (cred.username) host.telnetUser = cred.username; - if (cred.password) host.telnetPassword = cred.password; + if (vncEffectiveAuthType === "credential" && host.vncCredentialId) { + try { + const cred = await hostRepository.findCredentialByIdForUser( + host.vncCredentialId as number, + host.userId as string, + ); + if (cred) { + if (cred.password) host.vncPassword = cred.password; + if (cred.username) host.vncUser = cred.username; + } + } catch (e) { + guacLogger.warn("Failed to resolve VNC credential", { + operation: "guac_vnc_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + + if ( + telnetEffectiveAuthType === "credential" && + hostRecord.telnetCredentialId + ) { + try { + const cred = await hostRepository.findCredentialByIdForUser( + hostRecord.telnetCredentialId as number, + host.userId as string, + ); + if (cred) { + if (cred.username) host.telnetUser = cred.username; + if (cred.password) host.telnetPassword = cred.password; + } + } catch (e) { + guacLogger.warn("Failed to resolve Telnet credential", { + operation: "guac_telnet_credential_resolve", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); } - } catch (e) { - guacLogger.warn("Failed to resolve Telnet credential", { - operation: "guac_telnet_credential_resolve", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); } } @@ -448,7 +463,7 @@ router.post( if (jumpHosts.length > 0) { try { - const { resolveHostById } = await import("../ssh/host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId); if (jumpHost) { const tunnelPort = await new Promise((resolve, reject) => { @@ -526,6 +541,28 @@ router.post( ? { guacdPort: perConnectionGuacdPort } : {}), }; + const recordingEnabled = host.enableSessionLogging !== false; + const recordingName = `${crypto.randomUUID()}.guac`; + const recordingPath = + process.env.GUACD_RECORDING_PATH || + process.env.GUACD_RECORDING_BACKEND_PATH || + path.resolve(DATA_DIR, "session_recordings", "guacamole"); + const recordingMetadata = recordingEnabled + ? { + hostId, + userId, + protocol: connectionType as "rdp" | "vnc" | "telnet", + path: recordingName, + startedAt: new Date().toISOString(), + } + : undefined; + if (recordingEnabled) { + guacConfig["recording-path"] = recordingPath; + guacConfig["recording-name"] = recordingName; + guacConfig["create-recording-path"] = true; + guacConfig["recording-exclude-output"] = false; + guacConfig["recording-include-keys"] = true; + } switch (connectionType) { case "rdp": @@ -533,22 +570,28 @@ router.post( guacConfig["drive-path"] = "/drive"; guacConfig["create-drive-path"] = true; } - token = tokenService.createRdpToken(hostname, username, password, { - port, - domain, - security: - (host.rdpSecurity as string) || - (host.security as string) || - undefined, - "ignore-cert": - host.rdpIgnoreCert !== undefined - ? !!host.rdpIgnoreCert - : host.ignoreCert !== undefined - ? !!host.ignoreCert - : true, - ...guacConfig, - ...guacdOverrides, - }); + token = tokenService.createRdpToken( + hostname, + username, + password, + { + port, + domain, + security: + (host.rdpSecurity as string) || + (host.security as string) || + undefined, + "ignore-cert": + host.rdpIgnoreCert !== undefined + ? !!host.rdpIgnoreCert + : host.ignoreCert !== undefined + ? !!host.ignoreCert + : true, + ...guacConfig, + ...guacdOverrides, + }, + recordingMetadata, + ); break; case "vnc": token = tokenService.createVncToken( @@ -561,14 +604,21 @@ router.post( ...guacConfig, ...guacdOverrides, }, + recordingMetadata, ); break; case "telnet": - token = tokenService.createTelnetToken(hostname, username, password, { - port, - ...guacConfig, - ...guacdOverrides, - }); + token = tokenService.createTelnetToken( + hostname, + username, + password, + { + port, + ...guacConfig, + ...guacdOverrides, + }, + recordingMetadata, + ); break; default: return res.status(400).json({ error: "Invalid connection type" }); @@ -592,11 +642,8 @@ router.get("/status", async (req, res) => { try { let dbUrl: string | undefined; try { - const db = getDb(); - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; - dbUrl = urlRow?.value; + dbUrl = + (await createCurrentSettingsRepository().get("guac_url")) ?? undefined; } catch { // Fall back to env vars } diff --git a/src/backend/guacamole/token-service.ts b/src/backend/hosts/guacamole/token-service.ts similarity index 92% rename from src/backend/guacamole/token-service.ts rename to src/backend/hosts/guacamole/token-service.ts index f3322e06..d2fe23da 100644 --- a/src/backend/guacamole/token-service.ts +++ b/src/backend/hosts/guacamole/token-service.ts @@ -1,5 +1,5 @@ import crypto from "crypto"; -import { guacLogger } from "../utils/logger.js"; +import { guacLogger } from "../../utils/logger.js"; export interface GuacamoleConnectionSettings { type: "rdp" | "vnc" | "telnet"; @@ -30,6 +30,15 @@ export interface GuacamoleConnectionSettings { export interface GuacamoleToken { connection: GuacamoleConnectionSettings; + recording?: GuacamoleRecordingMetadata; +} + +export interface GuacamoleRecordingMetadata { + hostId: number; + userId: string; + protocol: "rdp" | "vnc" | "telnet"; + path: string; + startedAt: string; } const CIPHER = "aes-256-cbc"; @@ -127,6 +136,7 @@ export class GuacamoleTokenService { guacdHost?: string; guacdPort?: number; } = {}, + recording?: GuacamoleRecordingMetadata, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -144,6 +154,7 @@ export class GuacamoleTokenService { ...settingsOptions, }, }, + recording, }; return this.encryptToken(token); } @@ -156,6 +167,7 @@ export class GuacamoleTokenService { guacdHost?: string; guacdPort?: number; } = {}, + recording?: GuacamoleRecordingMetadata, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -171,6 +183,7 @@ export class GuacamoleTokenService { ...settingsOptions, }, }, + recording, }; return this.encryptToken(token); } @@ -183,6 +196,7 @@ export class GuacamoleTokenService { guacdHost?: string; guacdPort?: number; } = {}, + recording?: GuacamoleRecordingMetadata, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -198,6 +212,7 @@ export class GuacamoleTokenService { ...settingsOptions, }, }, + recording, }; return this.encryptToken(token); } diff --git a/src/backend/ssh/host-key-verifier.ts b/src/backend/hosts/host-key-verifier.ts similarity index 90% rename from src/backend/ssh/host-key-verifier.ts rename to src/backend/hosts/host-key-verifier.ts index baf20a95..111021ac 100644 --- a/src/backend/ssh/host-key-verifier.ts +++ b/src/backend/hosts/host-key-verifier.ts @@ -1,7 +1,5 @@ import type { WebSocket } from "ws"; -import { db } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; import { sshLogger } from "../utils/logger.js"; interface HostKeyVerificationData { @@ -36,17 +34,9 @@ export class SSHHostKeyVerifier { } | null> { if (!hostId) return null; try { - const host = await db.query.hosts.findFirst({ - where: eq(hosts.id, hostId), - columns: { - hostKeyFingerprint: true, - hostKeyType: true, - hostKeyAlgorithm: true, - hostKeyChangedCount: true, - name: true, - }, - }); - return host ?? null; + return await createCurrentHostResolutionRepository().findHostKeyVerificationData( + hostId, + ); } catch { return null; } @@ -89,7 +79,9 @@ export class SSHHostKeyVerifier { const host = preloadedHost !== undefined ? preloadedHost - : await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) }); + : await createCurrentHostResolutionRepository().findHostKeyVerificationData( + hostId, + ); if (!host) { sshLogger.warn( @@ -189,9 +181,8 @@ export class SSHHostKeyVerifier { // Verify first, then update the timestamp asynchronously so the // DB write doesn't delay the SSH key exchange critical path. verify(true); - db.update(hosts) - .set({ hostKeyLastVerified: new Date().toISOString() }) - .where(eq(hosts.id, hostId)) + createCurrentHostResolutionRepository() + .touchHostKeyLastVerified(hostId) .catch((err) => { sshLogger.error("Failed to update hostKeyLastVerified", err, { operation: "host_key_update_timestamp", @@ -314,16 +305,12 @@ export class SSHHostKeyVerifier { keyType: string, algorithm: string, ): Promise { - await db - .update(hosts) - .set({ - hostKeyFingerprint: fingerprint, - hostKeyType: keyType, - hostKeyAlgorithm: algorithm, - hostKeyFirstSeen: new Date().toISOString(), - hostKeyLastVerified: new Date().toISOString(), - }) - .where(eq(hosts.id, hostId)); + await createCurrentHostResolutionRepository().storeHostKey( + hostId, + fingerprint, + keyType, + algorithm, + ); } private static async updateHostKey( @@ -333,16 +320,13 @@ export class SSHHostKeyVerifier { algorithm: string, currentChangeCount: number, ): Promise { - await db - .update(hosts) - .set({ - hostKeyFingerprint: fingerprint, - hostKeyType: keyType, - hostKeyAlgorithm: algorithm, - hostKeyLastVerified: new Date().toISOString(), - hostKeyChangedCount: currentChangeCount + 1, - }) - .where(eq(hosts.id, hostId)); + await createCurrentHostResolutionRepository().updateHostKey( + hostId, + fingerprint, + keyType, + algorithm, + currentChangeCount, + ); } private static async promptUserForNewKey( diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts new file mode 100644 index 00000000..859d8e09 --- /dev/null +++ b/src/backend/hosts/host-resolver.ts @@ -0,0 +1,293 @@ +import { + createCurrentHostResolutionRepository, + createCurrentVaultProfileRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import { logAudit } from "../utils/audit-logger.js"; +import { logger } from "../utils/logger.js"; +import { + pickResolvedPassword, + pickResolvedUsername, + expandOidcUsername, +} from "./credential-username.js"; +import type { SSHHost } from "../../types/index.js"; +import type { HostAction } from "../utils/permission-manager.js"; + +const sshLogger = logger; + +/** + * Resolve a host with its credentials server-side by hostId. + * This avoids passing credentials through the frontend. + */ +export async function resolveHostById( + hostId: number, + userId: string, +): Promise { + const { PermissionManager } = await import("../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + userId, + hostId, + "connect", + ); + if (!access.hasAccess) return null; + + const repository = createCurrentHostResolutionRepository(); + + // Decrypt under the owner's DEK: shared hosts carry owner-encrypted fields + // (socks5Password, inline auth, ...) that the requester's key cannot open. + const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId; + const resolvedHost = await repository.findHostById(hostId, ownerId); + if (!resolvedHost) return null; + + const host = resolvedHost as Record; + + // Admin bypass resolves like the owner would; every such access is audited. + const ownerEquivalent = userId === ownerId || access.isAdminBypass === true; + + if (access.isAdminBypass && userId !== ownerId) { + try { + const admin = await createCurrentUserRepository().findById(userId); + void logAudit({ + userId, + username: admin?.username ?? "unknown", + action: "admin_connect_host", + resourceType: "host", + resourceId: String(hostId), + resourceName: (host.name as string) || (host.ip as string) || "", + details: JSON.stringify({ ownerId }), + success: true, + }); + } catch { + // never block resolution on audit bookkeeping + } + } + + if (!ownerEquivalent) { + // Owner-only operational secrets are never shared. + host.sudoPassword = null; + host.autostartPassword = null; + host.autostartKey = null; + host.autostartKeyPassword = null; + } + + // Parse JSON fields + if (typeof host.jumpHosts === "string" && host.jumpHosts) { + try { + host.jumpHosts = JSON.parse(host.jumpHosts as string); + } catch { + host.jumpHosts = []; + } + } + if (typeof host.tunnelConnections === "string") { + try { + host.tunnelConnections = JSON.parse(host.tunnelConnections as string); + } catch { + host.tunnelConnections = []; + } + } + if (typeof host.statsConfig === "string" && host.statsConfig) { + try { + host.statsConfig = JSON.parse(host.statsConfig as string); + } catch { + host.statsConfig = undefined; + } + } + if (typeof host.terminalConfig === "string" && host.terminalConfig) { + try { + host.terminalConfig = JSON.parse(host.terminalConfig as string); + } catch { + host.terminalConfig = undefined; + } + } + if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) { + try { + host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string); + } catch { + host.socks5ProxyChain = []; + } + } + if (typeof host.quickActions === "string" && host.quickActions) { + try { + host.quickActions = JSON.parse(host.quickActions as string); + } catch { + host.quickActions = []; + } + } + + if (!ownerEquivalent) { + const resolved = await resolveSharedSshSecrets( + host, + hostId, + userId, + repository, + ); + if (!resolved) return null; + } else if (host.credentialId) { + try { + const cred = (await repository.findCredentialByIdForUser( + host.credentialId as number, + ownerId, + )) as Record | null; + + if (cred) { + host.password = pickResolvedPassword(host.password, cred.password); + // Prefer the normalised private key; fall back to raw key field + host.key = (cred.privateKey || cred.key) as string | null; + host.keyPassword = cred.keyPassword; + host.keyType = cred.keyType; + // CA-signed certificate for cert-based auth + (host as Record).certPublicKey = + cred.certPublicKey || null; + host.username = pickResolvedUsername( + host.username, + cred.username, + host.overrideCredentialUsername, + ); + host.authType = host.key ? "key" : host.password ? "password" : "none"; + } + } catch (e) { + sshLogger.warn("Failed to resolve credential for host", { + operation: "host_resolver_credential", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + + host.username = await expandOidcUsername( + host.username as string | undefined, + ownerEquivalent ? ownerId : userId, + ); + + // Resolve a Vault SSH signer profile (shared settings, no secrets). The + // certificate itself is obtained per-user at connect time via Vault OIDC. + if (host.vaultProfileId) { + try { + const profile = await createCurrentVaultProfileRepository().findById( + host.vaultProfileId as number, + ); + if (profile) { + (host as Record).vaultProfile = profile; + host.authType = "vault"; + } + } catch (e) { + sshLogger.warn("Failed to resolve vault profile for host", { + operation: "host_resolver_vault_profile", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + + return host as unknown as SSHHost; +} + +/** + * Fill in SSH auth secrets for a shared (non-owner) requester. Order: + * the recipient's own override credential, then their re-encrypted share + * snapshot. Secret-less auth types (opkssh, vault, agent, none) pass through + * untouched. Returns false when a secret-bearing host has no usable source. + */ +async function resolveSharedSshSecrets( + host: Record, + hostId: number, + userId: string, + repository: ReturnType, +): Promise { + try { + const overrideCredId = await repository.findOverrideCredentialId( + hostId, + userId, + ); + if (overrideCredId) { + const cred = (await repository.findCredentialByIdForUser( + overrideCredId, + userId, + )) as Record | null; + if (cred) { + host.password = cred.password; + host.key = (cred.privateKey || cred.key) as string | null; + host.keyPassword = cred.keyPassword; + host.keyType = cred.keyType; + host.username = pickResolvedUsername( + host.username, + cred.username, + host.overrideCredentialUsername, + ); + host.authType = host.key ? "key" : host.password ? "password" : "none"; + return true; + } + } + } catch { + // fall through to the share snapshot + } + + try { + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + "ssh", + ); + if (secret) { + host.password = secret.password; + host.key = secret.key; + host.keyPassword = secret.keyPassword; + host.keyType = secret.keyType; + host.username = pickResolvedUsername( + host.username, + secret.username, + host.overrideCredentialUsername, + ); + host.authType = secret.key + ? "key" + : secret.password + ? "password" + : "none"; + return true; + } + } catch (e) { + sshLogger.warn("Failed to get shared host secret", { + operation: "host_resolver_shared_secret", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + + const needsSecrets = + !!host.credentialId || + host.authType === "password" || + host.authType === "key" || + host.authType === "credential"; + if (!needsSecrets) return true; + + return false; +} + +/** + * Check if a user has access to a host (owner or shared access). + */ +export async function checkHostAccess( + hostId: number, + userId: string, + hostUserId: string, + requiredPermission: HostAction = "connect", +): Promise { + if (userId === hostUserId) return true; + + try { + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const permissionManager = PermissionManager.getInstance(); + const accessInfo = await permissionManager.canAccessHost( + userId, + hostId, + requiredPermission, + ); + return accessInfo.hasAccess; + } catch { + return false; + } +} diff --git a/src/backend/ssh/jump-host-chain.ts b/src/backend/hosts/jump-host-chain.ts similarity index 85% rename from src/backend/ssh/jump-host-chain.ts rename to src/backend/hosts/jump-host-chain.ts index b8b11b5c..5821fe76 100644 --- a/src/backend/ssh/jump-host-chain.ts +++ b/src/backend/hosts/jump-host-chain.ts @@ -1,14 +1,11 @@ -import { and, eq } from "drizzle-orm"; import { Client as SSHClient } from "ssh2"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; import { fileLogger } from "../utils/logger.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; @@ -38,39 +35,37 @@ async function resolveJumpHost( userId: string, ): Promise { try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId; + const resolvedHost = await repository.findHostById(hostId, ownerId); - if (hostResults.length === 0) { + if (!resolvedHost) { return null; } - const host = hostResults[0]; - const ownerId = (host.userId || userId) as string; + const host = resolvedHost as Record; if (host.credentialId) { if (userId !== ownerId) { try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + "ssh", + ); + if (secret) { return { ...host, - password: sharedCred.password, - key: sharedCred.key, - keyPassword: sharedCred.keyPassword, - keyType: sharedCred.keyType, - authType: sharedCred.key + password: secret.password, + key: secret.key, + keyPassword: secret.keyPassword, + keyType: secret.keyType, + authType: secret.key ? "key" - : sharedCred.password + : secret.password ? "password" : "none", } as JumpHostConfig; @@ -80,22 +75,12 @@ async function resolveJumpHost( } } - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", + const credential = (await repository.findCredentialByIdForUser( + host.credentialId as number, ownerId, - ); + )) as Record | null; - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { return { ...host, password: credential.password as string | undefined, diff --git a/src/backend/ssh/jump-host-proxy.ts b/src/backend/hosts/jump-host-proxy.ts similarity index 100% rename from src/backend/ssh/jump-host-proxy.ts rename to src/backend/hosts/jump-host-proxy.ts diff --git a/src/backend/ssh/alert-engine.ts b/src/backend/hosts/metrics/alert-engine.ts similarity index 62% rename from src/backend/ssh/alert-engine.ts rename to src/backend/hosts/metrics/alert-engine.ts index 2f1dfb73..deface56 100644 --- a/src/backend/ssh/alert-engine.ts +++ b/src/backend/hosts/metrics/alert-engine.ts @@ -1,10 +1,10 @@ -import { getDb } from "../database/db/index.js"; -import { statsLogger } from "../utils/logger.js"; +import { createCurrentAlertRepository } from "../../database/repositories/factory.js"; +import { statsLogger } from "../../utils/logger.js"; import { sendNotification, type AlertPayload, type NotificationChannel, -} from "../utils/notification-sender.js"; +} from "../../utils/notification-sender.js"; type AlertTriggerType = | "host_offline" @@ -28,25 +28,6 @@ interface AlertRule { cooldownMinutes: number; } -interface RawRule { - 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; -} - -interface RawChannel { - id: number; - type: string; - config: string; - enabled: number; -} - export class AlertEngine { private static instance: AlertEngine; @@ -74,7 +55,7 @@ export class AlertEngine { disk?: { percent: number | null } | null; }, ): Promise { - const rules = this.loadRulesForHost(hostId).filter((r) => + const rules = (await this.loadRulesForHost(hostId)).filter((r) => ["cpu_threshold", "memory_threshold", "disk_threshold"].includes( r.triggerType, ), @@ -106,9 +87,9 @@ export class AlertEngine { const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000; if ( now - breachStart >= durationMs && - !this.isCoolingDown(rule.id, hostId) + !(await this.isCoolingDown(rule.id, hostId)) ) { - const hostName = this.getHostName(hostId); + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { value: currentValue, message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`, @@ -133,13 +114,13 @@ export class AlertEngine { const triggerType: AlertTriggerType = isOnline ? "host_online" : "host_offline"; - const rules = this.loadRulesForHost(hostId).filter( + const rules = (await this.loadRulesForHost(hostId)).filter( (r) => r.triggerType === triggerType, ); for (const rule of rules) { - if (!this.isCoolingDown(rule.id, hostId)) { - const hostName = this.getHostName(hostId); + if (!(await this.isCoolingDown(rule.id, hostId))) { + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { message: isOnline ? `Host "${hostName}" is back online` @@ -169,13 +150,13 @@ export class AlertEngine { if (!triggerType) return; - const rules = this.loadRulesForHostUser(hostId, userId).filter( + const rules = (await this.loadRulesForHostUser(hostId, userId)).filter( (r) => r.triggerType === triggerType, ); for (const rule of rules) { - if (!this.isCoolingDown(rule.id, hostId)) { - const hostName = this.getHostName(hostId); + if (!(await this.isCoolingDown(rule.id, hostId))) { + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { message: ok ? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}` @@ -192,13 +173,13 @@ export class AlertEngine { sshUser: string, fromIp: string, ): Promise { - const rules = this.loadRulesForHostUser(hostId, userId).filter( + const rules = (await this.loadRulesForHostUser(hostId, userId)).filter( (r) => r.triggerType === "user_login", ); for (const rule of rules) { - if (!this.isCoolingDown(rule.id, hostId)) { - const hostName = this.getHostName(hostId); + if (!(await this.isCoolingDown(rule.id, hostId))) { + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`, severity: "info", @@ -233,28 +214,18 @@ export class AlertEngine { }; try { - const db = getDb(); - db.$client - .prepare( - `INSERT INTO alert_firings (user_id, rule_id, host_id, host_name, value, message, severity) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - rule.userId, - rule.id, - hostId, - hostName, - context.value ?? null, - context.message, - context.severity, - ); + const repository = createCurrentAlertRepository(); + await repository.createFiring({ + userId: rule.userId, + ruleId: rule.id, + hostId, + hostName, + value: context.value ?? null, + message: context.message, + severity: context.severity, + }); - // Prune old firings beyond 30 days - db.$client - .prepare( - `DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', '-30 days')`, - ) - .run(rule.userId); + repository.pruneFiringsOlderThan(rule.userId, 30); } catch (err) { statsLogger.warn("Failed to write alert firing", { operation: "alert_firing_insert_error", @@ -263,7 +234,7 @@ export class AlertEngine { }); } - const channels = this.loadChannelsForRule(rule.id); + const channels = await this.loadChannelsForRule(rule.id); for (const channel of channels) { sendNotification(channel, payload).catch((err) => { statsLogger.warn("Failed to send notification", { @@ -275,11 +246,14 @@ export class AlertEngine { } } - private isCoolingDown(ruleId: number, hostId: number): boolean { + private async isCoolingDown( + ruleId: number, + hostId: number, + ): Promise { const key = `${ruleId}:${hostId}`; const lastFired = this.cooldownMap.get(key); if (!lastFired) return false; - const rule = this.loadRuleById(ruleId); + const rule = await this.loadRuleById(ruleId); const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000; return Date.now() - lastFired < cooldownMs; } @@ -288,93 +262,60 @@ export class AlertEngine { this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now()); } - private loadRulesForHost(hostId: number): AlertRule[] { + private async loadRulesForHost(hostId: number): Promise { try { - const db = getDb(); - const rows = db.$client - .prepare( - `SELECT * FROM alert_rules - WHERE enabled = 1 AND (host_id = ? OR host_id IS NULL)`, - ) - .all(hostId) as RawRule[]; - return rows.map(mapRawRule); + return (await createCurrentAlertRepository().listEnabledRulesForHost( + hostId, + )) as AlertRule[]; } catch { return []; } } - private loadRulesForHostUser(hostId: number, userId: string): AlertRule[] { + private async loadRulesForHostUser( + hostId: number, + userId: string, + ): Promise { try { - const db = getDb(); - const rows = db.$client - .prepare( - `SELECT * FROM alert_rules - WHERE enabled = 1 AND user_id = ? AND (host_id = ? OR host_id IS NULL)`, - ) - .all(userId, hostId) as RawRule[]; - return rows.map(mapRawRule); + return (await createCurrentAlertRepository().listEnabledRulesForHostUser( + hostId, + userId, + )) as AlertRule[]; } catch { return []; } } - private loadRuleById(ruleId: number): AlertRule | null { + private async loadRuleById(ruleId: number): Promise { try { - const db = getDb(); - const row = db.$client - .prepare("SELECT * FROM alert_rules WHERE id = ?") - .get(ruleId) as RawRule | undefined; - return row ? mapRawRule(row) : null; + return (await createCurrentAlertRepository().findRuleById( + ruleId, + )) as AlertRule | null; } catch { return null; } } - private loadChannelsForRule(ruleId: number): NotificationChannel[] { + private async loadChannelsForRule( + ruleId: number, + ): Promise { try { - const db = getDb(); - const rows = db.$client - .prepare( - `SELECT nc.id, nc.type, nc.config, nc.enabled - FROM notification_channels nc - JOIN alert_rule_channels arc ON arc.channel_id = nc.id - WHERE arc.rule_id = ? AND nc.enabled = 1`, - ) - .all(ruleId) as RawChannel[]; - return rows.map((r) => ({ - id: r.id, - type: r.type, - config: r.config, - enabled: r.enabled === 1, - })); + return await createCurrentAlertRepository().listEnabledChannelsForRule( + ruleId, + ); } catch { return []; } } - private getHostName(hostId: number): string { + private async getHostName(hostId: number): Promise { try { - const db = getDb(); - const row = db.$client - .prepare("SELECT name, ip FROM ssh_data WHERE id = ?") - .get(hostId) as { name: string | null; ip: string } | undefined; - return row?.name || row?.ip || `Host #${hostId}`; + return ( + (await createCurrentAlertRepository().getHostDisplayName(hostId)) ?? + `Host #${hostId}` + ); } catch { return `Host #${hostId}`; } } } - -function mapRawRule(r: RawRule): AlertRule { - return { - id: r.id, - userId: r.user_id, - hostId: r.host_id, - name: r.name, - enabled: r.enabled === 1, - triggerType: r.trigger_type as AlertTriggerType, - thresholdValue: r.threshold_value, - thresholdDurationSeconds: r.threshold_duration_seconds, - cooldownMinutes: r.cooldown_minutes, - }; -} diff --git a/src/backend/hosts/metrics/helpers.ts b/src/backend/hosts/metrics/helpers.ts new file mode 100644 index 00000000..6760cfb8 --- /dev/null +++ b/src/backend/hosts/metrics/helpers.ts @@ -0,0 +1,48 @@ +import type { Client } from "ssh2"; + +export type StatsCapableHost = { + connectionType?: string; + authType?: string; +}; + +export type TcpPingStatsConfig = { + statusCheckEnabled: boolean; + disableTcpPing?: boolean; +}; + +export function supportsMetrics(host: StatsCapableHost): boolean { + const connectionType = host.connectionType || "ssh"; + if (connectionType !== "ssh") return false; + if (host.authType === "none" || host.authType === "opkssh") return false; + return true; +} + +export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean { + return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing; +} + +export function tcpPingThroughJumpHost( + jumpClient: Pick, + host: string, + port: number, + timeoutMs = 5000, +): Promise { + return new Promise((resolve) => { + let settled = false; + + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + jumpClient.end(); + resolve(result); + }; + + const timeout = setTimeout(() => finish(false), timeoutMs); + + jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => { + stream?.destroy(); + finish(!error && !!stream); + }); + }); +} diff --git a/src/backend/ssh/host-metrics-history-routes.ts b/src/backend/hosts/metrics/history-routes.ts similarity index 80% rename from src/backend/ssh/host-metrics-history-routes.ts rename to src/backend/hosts/metrics/history-routes.ts index 05caddef..03fe2840 100644 --- a/src/backend/ssh/host-metrics-history-routes.ts +++ b/src/backend/hosts/metrics/history-routes.ts @@ -1,14 +1,15 @@ import type { Express, RequestHandler } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { getDb } from "../database/db/index.js"; -import { statsLogger } from "../utils/logger.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { createCurrentHostMetricsHistoryRepository } from "../../database/repositories/factory.js"; +import { statsLogger } from "../../utils/logger.js"; +import type { HostAction } from "../../utils/permission-manager.js"; type HistoryRoutesDeps = { validateHostId: RequestHandler; canAccessHost: ( userId: string, hostId: number, - level: "read" | "write" | "execute" | "delete" | "share", + level: HostAction, ) => Promise; }; @@ -65,7 +66,7 @@ export function registerHostMetricsHistoryRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const hasAccess = await canAccessHost(userId, hostId, "read"); + const hasAccess = await canAccessHost(userId, hostId, "connect"); if (!hasAccess) { return res.status(403).json({ error: "Access denied" }); } @@ -98,19 +99,24 @@ export function registerHostMetricsHistoryRoutes( fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); } - const db = getDb(); // SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z). // Normalize both sides to that format for reliable string comparison. const toSqlite = (iso: string) => iso.replace("T", " ").replace(/\.\d{3}Z$/, ""); - const rows = db.$client - .prepare( - `SELECT ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes - FROM host_metrics_history - WHERE host_id = ? AND ts >= ? AND ts <= ? - ORDER BY ts ASC`, + const rows = ( + await createCurrentHostMetricsHistoryRepository().listRange( + hostId, + toSqlite(fromTs), + toSqlite(toTs), ) - .all(hostId, toSqlite(fromTs), toSqlite(toTs)); + ).map((row) => ({ + ts: row.ts, + cpu_percent: row.cpuPercent, + mem_percent: row.memPercent, + disk_percent: row.diskPercent, + net_rx_bytes: row.netRxBytes, + net_tx_bytes: row.netTxBytes, + })); res.json({ rows, fromTs, toTs }); } catch (error) { diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/hosts/metrics/index.ts similarity index 87% rename from src/backend/ssh/host-metrics.ts rename to src/backend/hosts/metrics/index.ts index 4ff506b3..b934a34d 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/hosts/metrics/index.ts @@ -1,19 +1,27 @@ import express from "express"; import net from "net"; -import { createCorsMiddleware } from "../utils/cors-config.js"; +import { createCorsMiddleware } from "../../utils/cors-config.js"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { pickResolvedUsername } from "./credential-username.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; -import { statsLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { PermissionManager } from "../utils/permission-manager.js"; -import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; -import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { + pickResolvedPassword, + pickResolvedUsername, +} from "../credential-username.js"; +import { + getCurrentSettingValue, + createCurrentHostMetricsHistoryRepository, + createCurrentHostResolutionRepository, +} from "../../database/repositories/factory.js"; +import { statsLogger } from "../../utils/logger.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; +import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js"; +import type { + LogEntry, + ConnectionStage, +} from "../../../types/connection-log.js"; import { collectCpuMetrics } from "./widgets/cpu-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js"; import { collectDiskMetrics } from "./widgets/disk-collector.js"; @@ -24,29 +32,29 @@ import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; -import { collectTemperatureMetrics } from "./widgets/temperature-collector.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { connectionPool, withConnection } from "./ssh-connection-pool.js"; -import { registerHostMetricsSettingsRoutes } from "./host-metrics-settings-routes.js"; -import { registerHostMetricsViewerRoutes } from "./host-metrics-viewer-routes.js"; -import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences-routes.js"; -import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js"; +} from "../../utils/socks5-helper.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { SSHHostKeyVerifier } from "../host-key-verifier.js"; +import { connectionPool, withConnection } from "../ssh-connection-pool.js"; +import { registerHostMetricsSettingsRoutes } from "./settings-routes.js"; +import { registerHostMetricsViewerRoutes } from "./viewer-routes.js"; +import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js"; +import { registerHostMetricsHistoryRoutes } from "./history-routes.js"; import { AlertEngine } from "./alert-engine.js"; import { registerManagerRoutes } from "./managers/index.js"; +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; import { AccessDeniedError } from "./managers/route-helpers.js"; import type { ManagerHost } from "./managers/types.js"; -import { createJumpHostChain } from "./host-metrics-jump-hosts.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; import { - createConnectionLog, isTcpPingEnabled, supportsMetrics, -} from "./host-metrics-helpers.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; + tcpPingThroughJumpHost, +} from "./helpers.js"; +import { createConnectionLog } from "../connection-log.js"; import { cleanupMetricsSession, getSessionKey, @@ -54,13 +62,16 @@ import { pendingTOTPSessions, scheduleMetricsSessionCleanup, type MetricsViewer, -} from "./host-metrics-sessions.js"; +} from "./sessions.js"; import { authFailureTracker, + hostPollCache, metricsCache, + metricsPollLimiter, pollingBackoff, requestQueue, -} from "./host-metrics-state.js"; + statusPollLimiter, +} from "./state.js"; const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); @@ -104,6 +115,7 @@ interface SSHHostWithCredentials { rdpPort?: number; vncPort?: number; telnetPort?: number; + vaultProfile?: { id?: number | null } | null; } type StatusEntry = { @@ -134,7 +146,6 @@ const DEFAULT_STATS_CONFIG: StatsConfig = { "processes", "ports", "firewall", - "temperature", ], statusCheckEnabled: true, statusCheckInterval: 60, @@ -163,6 +174,9 @@ class PollingManager { private activeViewers = new Map>(); private viewerDetails = new Map(); private viewerCleanupInterval: NodeJS.Timeout; + /** Skip stacking another status/metrics poll while one is already running. */ + private statusInFlight = new Set(); + private metricsInFlight = new Set(); constructor() { this.viewerCleanupInterval = setInterval(() => { @@ -170,31 +184,98 @@ class PollingManager { }, 60000); } + /** Spread timers so N hosts do not fire on the same wall-clock second. */ + private intervalWithJitter(intervalMs: number, hostId: number): number { + const spread = Math.min(intervalMs * 0.2, 15_000); + const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread)); + return intervalMs + jitter; + } + + private async resolveHostForPoll( + host: SSHHostWithCredentials, + userId: string, + ): Promise { + const cached = hostPollCache.get( + host.id, + userId, + ) as SSHHostWithCredentials | null; + if (cached) { + return cached; + } + + const refreshed = await fetchHostById(host.id, userId); + if (!refreshed) { + return null; + } + + hostPollCache.set(host.id, userId, refreshed); + const config = this.pollingConfigs.get(host.id); + if (config) { + config.host = refreshed; + config.statsConfig = this.parseStatsConfig(refreshed.statsConfig); + } + return refreshed; + } + + private scheduleStatusPoll( + host: SSHHostWithCredentials, + viewerUserId?: string, + ): void { + if (this.statusInFlight.has(host.id)) { + return; + } + this.statusInFlight.add(host.id); + void statusPollLimiter + .run(() => this.pollHostStatus(host, viewerUserId)) + .catch((err) => { + statsLogger.error("Status polling failed", err, { + operation: "status_poll_unhandled", + hostId: host.id, + }); + }) + .finally(() => { + this.statusInFlight.delete(host.id); + }); + } + + private scheduleMetricsPoll( + host: SSHHostWithCredentials, + viewerUserId?: string, + ): void { + if (this.metricsInFlight.has(host.id)) { + return; + } + this.metricsInFlight.add(host.id); + void metricsPollLimiter + .run(() => this.pollHostMetrics(host, viewerUserId)) + .catch((err) => { + statsLogger.error("Metrics polling failed", err, { + operation: "metrics_poll_unhandled", + hostId: host.id, + }); + }) + .finally(() => { + this.metricsInFlight.delete(host.id); + }); + } + private getGlobalDefaults(): { statusCheckInterval: number; metricsInterval: number; } { try { - const db = getDb(); - const statusRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_status_check_interval'", - ) - .get() as { value: string } | undefined; - const metricsRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_metrics_interval'", - ) - .get() as { value: string } | undefined; + const statusValue = getCurrentSettingValue( + "global_status_check_interval", + ); + const metricsValue = getCurrentSettingValue("global_metrics_interval"); return { - statusCheckInterval: statusRow - ? parseInt(statusRow.value, 10) || + statusCheckInterval: statusValue + ? parseInt(statusValue, 10) || DEFAULT_STATS_CONFIG.statusCheckInterval : DEFAULT_STATS_CONFIG.statusCheckInterval, - metricsInterval: metricsRow - ? parseInt(metricsRow.value, 10) || - DEFAULT_STATS_CONFIG.metricsInterval + metricsInterval: metricsValue + ? parseInt(metricsValue, 10) || DEFAULT_STATS_CONFIG.metricsInterval : DEFAULT_STATS_CONFIG.metricsInterval, }; } catch { @@ -300,17 +381,18 @@ class PollingManager { viewerUserId, }; - this.pollingConfigs.set(host.id, config); - if (isTcpPingEnabled(statsConfig)) { - const intervalMs = statsConfig.statusCheckInterval * 1000; + const intervalMs = this.intervalWithJitter( + statsConfig.statusCheckInterval * 1000, + host.id, + ); - this.pollHostStatus(host, viewerUserId); + this.scheduleStatusPoll(host, viewerUserId); config.statusTimer = setInterval(() => { const latestConfig = this.pollingConfigs.get(host.id); if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) { - this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId); + this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId); } }, intervalMs); } else { @@ -318,9 +400,27 @@ class PollingManager { } if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) { - const intervalMs = statsConfig.metricsInterval * 1000; + const intervalMs = this.intervalWithJitter( + statsConfig.metricsInterval * 1000, + host.id, + ); - await this.pollHostMetrics(host, viewerUserId); + // First sample still awaited (gated) so callers can rely on a warm cache. + if (!this.metricsInFlight.has(host.id)) { + this.metricsInFlight.add(host.id); + try { + await metricsPollLimiter.run(() => + this.pollHostMetrics(host, viewerUserId), + ); + } catch (err) { + statsLogger.error("Metrics polling failed", err, { + operation: "metrics_poll_unhandled", + hostId: host.id, + }); + } finally { + this.metricsInFlight.delete(host.id); + } + } config.metricsTimer = setInterval(() => { const latestConfig = this.pollingConfigs.get(host.id); @@ -329,20 +429,17 @@ class PollingManager { latestConfig.statsConfig.metricsEnabled && supportsMetrics(latestConfig.host) ) { - this.pollHostMetrics( + this.scheduleMetricsPoll( latestConfig.host, latestConfig.viewerUserId, - ).catch((err) => { - statsLogger.error("Metrics polling failed", err, { - operation: "metrics_poll_unhandled", - hostId: host.id, - }); - }); + ); } }, intervalMs); } else { this.metricsStore.delete(host.id); } + + this.pollingConfigs.set(host.id, config); } private async pollHostStatus( @@ -350,30 +447,51 @@ class PollingManager { viewerUserId?: string, ): Promise { const userId = viewerUserId || host.userId; - const refreshedHost = await fetchHostById(host.id, userId); + const refreshedHost = await this.resolveHostForPoll(host, userId); if (!refreshedHost) { return; } try { - let pingHost = refreshedHost.ip; const ct = refreshedHost.connectionType || "ssh"; let pingPort: number; if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389; else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900; else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23; else pingPort = refreshedHost.port; + + let isOnline: boolean; if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) { - const firstJump = await fetchHostById( - refreshedHost.jumpHosts[0].hostId, + const proxyConfig: SOCKS5Config | null = + refreshedHost.useSocks5 && + (refreshedHost.socks5Host || + (refreshedHost.socks5ProxyChain && + refreshedHost.socks5ProxyChain.length > 0)) + ? { + useSocks5: true, + socks5Host: refreshedHost.socks5Host, + socks5Port: refreshedHost.socks5Port, + socks5Username: refreshedHost.socks5Username, + socks5Password: refreshedHost.socks5Password, + socks5ProxyChain: refreshedHost.socks5ProxyChain, + } + : null; + const jumpClient = await createJumpHostChain( + refreshedHost.jumpHosts, userId, + proxyConfig, ); - if (firstJump) { - pingHost = firstJump.ip; - pingPort = firstJump.port; - } + isOnline = jumpClient + ? await tcpPingThroughJumpHost( + jumpClient, + refreshedHost.ip, + pingPort, + 5000, + ) + : false; + } else { + isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000); } - const isOnline = await tcpPing(pingHost, pingPort, 5000); const statusEntry: StatusEntry = { status: isOnline ? "online" : "offline", lastChecked: new Date().toISOString(), @@ -399,7 +517,7 @@ class PollingManager { viewerUserId?: string, ): Promise { const userId = viewerUserId || host.userId; - const refreshedHost = await fetchHostById(host.id, userId); + const refreshedHost = await this.resolveHostForPoll(host, userId); if (!refreshedHost) { return; } @@ -432,7 +550,7 @@ class PollingManager { data: metrics, timestamp: Date.now(), }); - this.insertMetricsHistory(refreshedHost.id, metrics); + await this.insertMetricsHistory(refreshedHost.id, metrics); AlertEngine.getInstance() .evaluateMetrics(refreshedHost.id, metrics) .catch(() => {}); @@ -476,20 +594,15 @@ class PollingManager { private getRetentionDays(): number { try { - const db = getDb(); - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'metrics_history_retention_days'", - ) - .get() as { value: string } | undefined; - const days = row ? parseInt(row.value, 10) : 7; + const value = getCurrentSettingValue("metrics_history_retention_days"); + const days = value ? parseInt(value, 10) : 7; return isNaN(days) || days < 1 ? 7 : Math.min(days, 90); } catch { return 7; } } - private insertMetricsHistory( + private async insertMetricsHistory( hostId: number, metrics: { cpu: { percent: number | null }; @@ -499,34 +612,24 @@ class PollingManager { interfaces: Array<{ rxBytes: string | null; txBytes: string | null }>; }; }, - ): void { + ): Promise { try { - const db = getDb(); const iface = metrics.network?.interfaces?.[0]; const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null; const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null; - db.$client - .prepare( - `INSERT INTO host_metrics_history - (host_id, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes) - VALUES (?, ?, ?, ?, ?, ?)`, - ) - .run( - hostId, - metrics.cpu?.percent ?? null, - metrics.memory?.percent ?? null, - metrics.disk?.percent ?? null, - isNaN(rxRaw as number) ? null : rxRaw, - isNaN(txRaw as number) ? null : txRaw, - ); + const repository = createCurrentHostMetricsHistoryRepository(); + await repository.create({ + hostId, + cpuPercent: metrics.cpu?.percent ?? null, + memPercent: metrics.memory?.percent ?? null, + diskPercent: metrics.disk?.percent ?? null, + netRxBytes: isNaN(rxRaw as number) ? null : rxRaw, + netTxBytes: isNaN(txRaw as number) ? null : txRaw, + }); const retentionDays = this.getRetentionDays(); - db.$client - .prepare( - `DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)`, - ) - .run(hostId, `-${retentionDays} days`); + repository.pruneOlderThan(hostId, retentionDays); } catch (err) { statsLogger.warn("Failed to write metrics history", { operation: "insert_metrics_history", @@ -554,6 +657,9 @@ class PollingManager { this.metricsStore.delete(hostId); } } + hostPollCache.invalidate(hostId); + this.statusInFlight.delete(hostId); + this.metricsInFlight.delete(hostId); } stopMetricsOnly(hostId: number): void { @@ -756,11 +862,8 @@ async function fetchAllHosts( userId: string, ): Promise { try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const hostResults = await repository.findHostsByUserId(userId); const hostsWithCredentials: SSHHostWithCredentials[] = []; for (const host of hostResults) { @@ -788,14 +891,14 @@ async function fetchHostById( userId: string, ): Promise { try { - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return undefined; } const accessInfo = await permissionManager.canAccessHost( userId, id, - "read", + "connect", ); if (!accessInfo.hasAccess) { @@ -807,17 +910,13 @@ async function fetchHostById( return undefined; } - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, id)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const host = await repository.findHostById(id, userId); - if (hostResults.length === 0) { + if (!host) { return undefined; } - const host = hostResults[0]; return await resolveHostCredentials(host, userId); } catch (err) { statsLogger.error(`Failed to fetch host ${id}`, err); @@ -845,15 +944,6 @@ async function resolveHostCredentials( : [], pin: !!host.pin, authType: host.authType, - terminalConfig: (() => { - try { - return typeof host.terminalConfig === "string" - ? JSON.parse(host.terminalConfig as string) - : host.terminalConfig || undefined; - } catch { - return undefined; - } - })(), enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: !!host.enableFileManager, @@ -901,13 +991,14 @@ async function resolveHostCredentials( const isSharedHost = userId !== ownerId; if (isSharedHost) { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - host.id as number, - userId, - ); + const { SharedHostSecretsManager } = + await import("../../utils/shared-host-secrets-manager.js"); + const sharedCred = + await SharedHostSecretsManager.getInstance().getSecretForUser( + host.id as number, + userId, + "ssh", + ); if (sharedCred) { baseHost.credentialId = host.credentialId; @@ -933,17 +1024,13 @@ async function resolveHostCredentials( } } } else { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, host.credentialId as number)), - "ssh_credentials", - userId, - ); + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + host.credentialId as number, + userId, + ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { baseHost.credentialId = credential.id; baseHost.authType = credential.authType || @@ -960,9 +1047,10 @@ async function resolveHostCredentials( host.overrideCredentialUsername, ); - if (credential.password) { - baseHost.password = credential.password; - } + baseHost.password = pickResolvedPassword( + host.password, + credential.password, + ); if ( credential.key || (credential as Record).privateKey @@ -1128,6 +1216,8 @@ async function buildSshConfig( // no credentials needed } else if (host.authType === "opkssh") { // cert auth setup happens in createSshFactory (needs client instance) + } else if (host.authType === "vault") { + // cert auth setup happens in createSshFactory (needs client instance) } else if (host.authType === "credential") { if (host.password) { base.password = host.password; @@ -1146,14 +1236,6 @@ async function buildSshConfig( } else { throw new Error(`Credential for host ${host.ip} could not be resolved`); } - } else if (host.authType === "agent") { - const result = await applyAgentAuth( - base as Record, - (host as { terminalConfig?: Record }).terminalConfig, - ); - if ("error" in result) { - throw new Error(result.error); - } } else { throw new Error( `Unsupported authentication type '${host.authType}' for host ${host.ip}`, @@ -1177,15 +1259,19 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { // Set up OPKSSH cert auth if needed (requires client instance) if (host.authType === "opkssh" && host.userId) { - const { getOPKSSHToken } = await import("./opkssh-auth.js"); + const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(host.userId, host.id); if (!token) { throw new Error( "OPKSSH authentication required. Please open a Terminal connection first.", ); } - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth(config, client, token, host.username); + } else if (host.authType === "vault") { + const { setupVaultSshSignerAuth } = + await import("../vault-ssh-connect.js"); + await setupVaultSshSignerAuth(config, client, host); } const proxyConfig: SOCKS5Config | null = @@ -1331,8 +1417,18 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { client.connect(config); }, ); - } else { + } else if (config.sock) { client.connect(config); + } else { + resolveSshConnectConfigHost(config) + .then(() => { + client.connect(config); + }) + .catch((error) => { + clearTimeout(timeout); + reject(error); + }); + return; } }); }; @@ -1393,14 +1489,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ kernel: string | null; os: string | null; }; - temperature: { - source: "sysfs" | "sensors" | "none"; - highestCelsius: number | null; - sensors: Array<{ - label: string; - celsius: number; - }>; - }; }> { if (!supportsMetrics(host)) { throw new Error("Metrics collection only supported for SSH hosts"); @@ -1431,7 +1519,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ const uptime = await collectUptimeMetrics(client); const processes = await collectProcessesMetrics(client); const system = await collectSystemMetrics(client); - const temperature = await collectTemperatureMetrics(client); let login_stats = { recentLogins: [], @@ -1503,7 +1590,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ uptime, processes, system, - temperature, login_stats, ports, firewall, @@ -1631,7 +1717,7 @@ function tcpPing( app.get("/status", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1645,7 +1731,10 @@ app.get("/status", async (req, res) => { const result: Record = {}; for (const [id, entry] of pollingManager.getAllStatuses().entries()) { - result[id] = entry; + const access = await permissionManager.canAccessHost(userId, id, "connect"); + if (access.hasAccess) { + result[id] = entry; + } } res.json(result); }); @@ -1676,13 +1765,18 @@ app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } + const access = await permissionManager.canAccessHost(userId, id, "connect"); + if (!access.hasAccess) { + return res.status(404).json({ error: "Status not available" }); + } + const statuses = pollingManager.getAllStatuses(); if (statuses.size === 0) { await pollingManager.initializePolling(userId); @@ -1710,10 +1804,10 @@ app.get("/status/:id", validateHostId, async (req, res) => { * 401: * description: Session expired - please log in again. */ -app.post("/clear-connections", async (req, res) => { +app.post("/clear-connections", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1729,7 +1823,7 @@ app.post("/clear-connections", async (req, res) => { * /refresh: * post: * summary: Refresh polling - * description: Clears all SSH connections and refreshes host polling. + * description: Refreshes host polling for the authenticated user. * tags: * - Host Metrics * responses: @@ -1741,15 +1835,13 @@ app.post("/clear-connections", async (req, res) => { app.post("/refresh", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } - connectionPool.clearAllConnections(); - await pollingManager.refreshHostPolling(userId); res.json({ message: "Polling refreshed" }); }); @@ -1787,7 +1879,7 @@ app.post("/host-updated", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1799,6 +1891,7 @@ app.post("/host-updated", async (req, res) => { } try { + hostPollCache.invalidate(hostId); const host = await fetchHostById(hostId, userId); if (host) { connectionPool.clearKeyConnections(getPoolKey(host)); @@ -1849,7 +1942,7 @@ app.post("/host-deleted", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1899,7 +1992,7 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1962,7 +2055,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { const connectionLogs: Array> = []; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { connectionLogs.push( createConnectionLog("error", "stats_connecting", "Session expired"), ); @@ -1982,20 +2075,6 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { return res.status(404).json({ error: "Host not found", connectionLogs }); } - if (!supportsMetrics(host)) { - connectionLogs.push( - createConnectionLog( - "info", - "stats_connecting", - "Metrics collection is only supported for SSH hosts", - { - connectionType: host.connectionType || "ssh", - }, - ), - ); - return res.json({ success: true, skipped: true, connectionLogs }); - } - connectionLogs.push( createConnectionLog( "info", @@ -2041,18 +2120,14 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { "Using existing metrics session", ), ); - - const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - pollingManager.registerViewer(host.id, viewerSessionId, userId); - - return res.json({ success: true, viewerSessionId, connectionLogs }); + return res.json({ success: true, connectionLogs }); } const config = await buildSshConfig(host); const client = new Client(); if (host.authType === "opkssh" && host.userId) { - const { getOPKSSHToken } = await import("./opkssh-auth.js"); + const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(host.userId, host.id); if (!token) { connectionLogs.push( @@ -2068,8 +2143,12 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { connectionLogs, }); } - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth(config, client, token, host.username); + } else if (host.authType === "vault") { + const { setupVaultSshSignerAuth } = + await import("../vault-ssh-connect.js"); + await setupVaultSshSignerAuth(config, client, host); } const connectionPromise = new Promise<{ @@ -2357,7 +2436,17 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { } }); } else { - client.connect(config); + resolveSshConnectConfigHost(config) + .then(() => { + client.connect(config); + }) + .catch((error) => { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + reject(error); + } + }); } }); @@ -2422,7 +2511,7 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { viewerSessionId } = req.body; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -2494,7 +2583,7 @@ app.post("/metrics/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -2707,7 +2796,8 @@ app.post("/internal/login-alert", async (req, res) => { ) { return res.status(403).json({ error: "Forbidden" }); } - const systemCrypto = (await import("../utils/system-crypto.js")).SystemCrypto; + const systemCrypto = (await import("../../utils/system-crypto.js")) + .SystemCrypto; const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); const token = req.headers["x-internal-auth"]; if (!token || token !== expectedToken) { diff --git a/src/backend/ssh/managers/cron.ts b/src/backend/hosts/metrics/managers/cron.ts similarity index 97% rename from src/backend/ssh/managers/cron.ts rename to src/backend/hosts/metrics/managers/cron.ts index ce587db0..08ac8d02 100644 --- a/src/backend/ssh/managers/cron.ts +++ b/src/backend/hosts/metrics/managers/cron.ts @@ -85,7 +85,7 @@ export function registerCronRoutes( app.get( "/host-metrics/managers/cron/:id", validateHostId, - managerHandler(runOnHost, "read", "cron_list", async (client) => { + managerHandler(runOnHost, "connect", "cron_list", async (client) => { const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000); return { entries: parseCrontab(stdout) }; }), @@ -96,7 +96,7 @@ export function registerCronRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "cron_replace", async (client, _host, req) => { const { entries } = req.body as { entries?: CronEntry[] }; diff --git a/src/backend/ssh/managers/exec-elevated.ts b/src/backend/hosts/metrics/managers/exec-elevated.ts similarity index 100% rename from src/backend/ssh/managers/exec-elevated.ts rename to src/backend/hosts/metrics/managers/exec-elevated.ts diff --git a/src/backend/ssh/managers/firewall.ts b/src/backend/hosts/metrics/managers/firewall.ts similarity index 97% rename from src/backend/ssh/managers/firewall.ts rename to src/backend/hosts/metrics/managers/firewall.ts index 32f80f42..535c289b 100644 --- a/src/backend/ssh/managers/firewall.ts +++ b/src/backend/hosts/metrics/managers/firewall.ts @@ -51,7 +51,7 @@ export function registerFirewallRoutes( app.get( "/host-metrics/managers/firewall/:id", validateHostId, - managerHandler(runOnHost, "read", "firewall_read", async (client) => { + managerHandler(runOnHost, "connect", "firewall_read", async (client) => { return await collectFirewallMetrics(client); }), ); @@ -61,7 +61,7 @@ export function registerFirewallRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "firewall_rule", async (client, host, req) => { const { op, protocol, port, target } = req.body as { @@ -106,7 +106,7 @@ export function registerFirewallRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "firewall_persist", async (client, host) => { const platform = await detectPlatform(client); diff --git a/src/backend/ssh/managers/health.ts b/src/backend/hosts/metrics/managers/health.ts similarity index 69% rename from src/backend/ssh/managers/health.ts rename to src/backend/hosts/metrics/managers/health.ts index f8ded3f5..f84928a9 100644 --- a/src/backend/ssh/managers/health.ts +++ b/src/backend/hosts/metrics/managers/health.ts @@ -1,8 +1,8 @@ import type { Express } from "express"; import type { Client } from "ssh2"; -import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { AuthenticatedRequest } from "../../../../types/index.js"; import { execCommand } from "../widgets/common-utils.js"; -import { getDb, DatabaseSaveTrigger } from "../../database/db/index.js"; +import { createCurrentHostHealthRepository } from "../../../database/repositories/factory.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { shellSingleQuote } from "./exec-elevated.js"; import { isValidPort } from "./validation.js"; @@ -115,46 +115,20 @@ function recordHistory( userId: string, hostId: number, results: HealthResult[], -): void { - const db = getDb(); - const now = new Date().toISOString(); - const insert = db.$client.prepare( - "INSERT INTO host_health_history (user_id, host_id, check_id, ts, ok, latency_ms, detail) VALUES (?, ?, ?, ?, ?, ?, ?)", - ); - for (const r of results) { - insert.run( - userId, - hostId, - r.checkId, - now, - r.ok ? 1 : 0, - r.latencyMs, - r.detail, - ); - } - // Prune to the most recent HISTORY_KEEP rows per (host, check). - db.$client - .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, HISTORY_KEEP); +): Promise { + return createCurrentHostHealthRepository() + .recordHistory(userId, hostId, results, HISTORY_KEEP) + .then(() => undefined); } -function loadChecks(userId: string, hostId: number): HealthCheck[] { - const row = getDb() - .$client.prepare( - "SELECT checks FROM host_health_checks WHERE user_id = ? AND host_id = ?", - ) - .get(userId, hostId) as { checks: string } | undefined; +async function loadChecks( + userId: string, + hostId: number, +): Promise { + const row = await createCurrentHostHealthRepository().findChecksByUserAndHost( + userId, + hostId, + ); if (!row?.checks) return []; try { const parsed = JSON.parse(row.checks); @@ -173,14 +147,14 @@ export function registerHealthRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "health_get", async (client, host, req) => { const userId = (req as AuthenticatedRequest).userId; - const checks = loadChecks(userId, host.id); + const checks = await loadChecks(userId, host.id); const results = checks.length ? await runChecks(client, checks) : []; if (results.length) { - recordHistory(userId, host.id, results); + await recordHistory(userId, host.id, results); for (const r of results) { AlertEngine.getInstance() .evaluateHealthCheck( @@ -194,11 +168,19 @@ export function registerHealthRoutes( } } - const history = getDb() - .$client.prepare( - "SELECT check_id as checkId, ts, ok, latency_ms as latencyMs, detail FROM host_health_history WHERE user_id = ? AND host_id = ? ORDER BY ts DESC LIMIT 200", + const history = ( + await createCurrentHostHealthRepository().listHistory( + userId, + host.id, + 200, ) - .all(userId, host.id); + ).map((row) => ({ + checkId: row.checkId, + ts: row.ts, + ok: row.ok, + latencyMs: row.latencyMs, + detail: row.detail, + })); return { checks, results, history }; }, ), @@ -209,7 +191,7 @@ export function registerHealthRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "health_config", async (_client, host, req) => { const userId = (req as AuthenticatedRequest).userId; @@ -226,27 +208,14 @@ export function registerHealthRoutes( intervalSeconds <= 86400 ? Math.round(intervalSeconds) : 300; - const db = getDb(); const now = new Date().toISOString(); - const existing = db.$client - .prepare( - "SELECT id FROM host_health_checks WHERE user_id = ? AND host_id = ?", - ) - .get(userId, host.id) as { id: number } | undefined; - if (existing) { - db.$client - .prepare( - "UPDATE host_health_checks SET checks = ?, interval_seconds = ?, updated_at = ? WHERE id = ?", - ) - .run(JSON.stringify(checks), interval, now, existing.id); - } else { - db.$client - .prepare( - "INSERT INTO host_health_checks (user_id, host_id, checks, interval_seconds, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run(userId, host.id, JSON.stringify(checks), interval, now, now); - } - DatabaseSaveTrigger.triggerSave("host_health_checks_updated"); + await createCurrentHostHealthRepository().upsertChecks( + userId, + host.id, + JSON.stringify(checks), + interval, + now, + ); return { success: true }; }, ), @@ -257,14 +226,14 @@ export function registerHealthRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "health_run", async (client, host, req) => { const userId = (req as AuthenticatedRequest).userId; - const checks = loadChecks(userId, host.id); + const checks = await loadChecks(userId, host.id); const results = await runChecks(client, checks); if (results.length) { - recordHistory(userId, host.id, results); + await recordHistory(userId, host.id, results); for (const r of results) { AlertEngine.getInstance() .evaluateHealthCheck( diff --git a/src/backend/ssh/managers/index.ts b/src/backend/hosts/metrics/managers/index.ts similarity index 96% rename from src/backend/ssh/managers/index.ts rename to src/backend/hosts/metrics/managers/index.ts index 2a281dd5..f4e79d1e 100644 --- a/src/backend/ssh/managers/index.ts +++ b/src/backend/hosts/metrics/managers/index.ts @@ -45,7 +45,7 @@ export function registerManagerRoutes( app.get( "/host-metrics/platform/:id", validateHostId, - managerHandler(runOnHost, "read", "platform_detect", (client) => + managerHandler(runOnHost, "connect", "platform_detect", (client) => detectPlatform(client), ), ); diff --git a/src/backend/ssh/managers/logs.ts b/src/backend/hosts/metrics/managers/logs.ts similarity index 97% rename from src/backend/ssh/managers/logs.ts rename to src/backend/hosts/metrics/managers/logs.ts index e717759f..60517264 100644 --- a/src/backend/ssh/managers/logs.ts +++ b/src/backend/hosts/metrics/managers/logs.ts @@ -46,7 +46,7 @@ export function registerLogRoutes( app.get( "/host-metrics/managers/logs/:id/files", validateHostId, - managerHandler(runOnHost, "read", "logs_list", async (client) => { + managerHandler(runOnHost, "connect", "logs_list", async (client) => { const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000); const found = stdout .split("\n") @@ -62,7 +62,7 @@ export function registerLogRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "logs_tail", async (client, host, req) => { const path = req.query.path as string | undefined; diff --git a/src/backend/ssh/managers/packages.ts b/src/backend/hosts/metrics/managers/packages.ts similarity index 97% rename from src/backend/ssh/managers/packages.ts rename to src/backend/hosts/metrics/managers/packages.ts index 6c5c96d5..2dff740c 100644 --- a/src/backend/ssh/managers/packages.ts +++ b/src/backend/hosts/metrics/managers/packages.ts @@ -96,7 +96,7 @@ export function registerPackageRoutes( app.get( "/host-metrics/managers/packages/:id", validateHostId, - managerHandler(runOnHost, "read", "packages_list", async (client) => { + managerHandler(runOnHost, "connect", "packages_list", async (client) => { const platform = await detectPlatform(client); const cmd = buildListUpgradableCommand(platform.pkg); if (!cmd) return { pkg: platform.pkg, upgradable: [] }; @@ -113,7 +113,7 @@ export function registerPackageRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "packages_action", async (client, host, req) => { const { action, pkg: name } = req.body as { diff --git a/src/backend/ssh/managers/platform.ts b/src/backend/hosts/metrics/managers/platform.ts similarity index 100% rename from src/backend/ssh/managers/platform.ts rename to src/backend/hosts/metrics/managers/platform.ts diff --git a/src/backend/ssh/managers/processes.ts b/src/backend/hosts/metrics/managers/processes.ts similarity index 97% rename from src/backend/ssh/managers/processes.ts rename to src/backend/hosts/metrics/managers/processes.ts index 180dc69e..5e1e2045 100644 --- a/src/backend/ssh/managers/processes.ts +++ b/src/backend/hosts/metrics/managers/processes.ts @@ -70,7 +70,7 @@ export function registerProcessRoutes( app.get( "/host-metrics/managers/processes/:id", validateHostId, - managerHandler(runOnHost, "read", "processes_list", async (client) => { + managerHandler(runOnHost, "connect", "processes_list", async (client) => { const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000); return { processes: parseProcessList(stdout) }; }), @@ -106,7 +106,7 @@ export function registerProcessRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "processes_signal", async (client, host, req) => { const { pid, signal } = req.body as { diff --git a/src/backend/ssh/managers/route-helpers.ts b/src/backend/hosts/metrics/managers/route-helpers.ts similarity index 89% rename from src/backend/ssh/managers/route-helpers.ts rename to src/backend/hosts/metrics/managers/route-helpers.ts index 5dfb6301..03a11c4a 100644 --- a/src/backend/ssh/managers/route-helpers.ts +++ b/src/backend/hosts/metrics/managers/route-helpers.ts @@ -1,9 +1,10 @@ import type { Request, Response } from "express"; import type { Client } from "ssh2"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { statsLogger } from "../../utils/logger.js"; +import type { AuthenticatedRequest } from "../../../../types/index.js"; +import { statsLogger } from "../../../utils/logger.js"; import { ElevationError } from "./exec-elevated.js"; import type { ManagerHost, RunOnHost } from "./types.js"; +import type { HostAction } from "../../../utils/permission-manager.js"; export class AccessDeniedError extends Error { constructor(message = "No access to this host") { @@ -25,7 +26,7 @@ export class ManagerInputError extends Error { */ export function managerHandler( runOnHost: RunOnHost, - level: "read" | "execute", + level: HostAction, operation: string, fn: (client: Client, host: ManagerHost, req: Request) => Promise, ) { diff --git a/src/backend/ssh/managers/services.ts b/src/backend/hosts/metrics/managers/services.ts similarity index 97% rename from src/backend/ssh/managers/services.ts rename to src/backend/hosts/metrics/managers/services.ts index 63164833..724bc517 100644 --- a/src/backend/ssh/managers/services.ts +++ b/src/backend/hosts/metrics/managers/services.ts @@ -70,7 +70,7 @@ export function registerServiceRoutes( app.get( "/host-metrics/managers/services/:id", validateHostId, - managerHandler(runOnHost, "read", "services_list", async (client) => { + managerHandler(runOnHost, "connect", "services_list", async (client) => { const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000); return { services: parseServiceList(stdout) }; }), @@ -106,7 +106,7 @@ export function registerServiceRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "services_action", async (client, host, req) => { const { unit, action } = req.body as { diff --git a/src/backend/ssh/managers/simple-reads.ts b/src/backend/hosts/metrics/managers/simple-reads.ts similarity index 94% rename from src/backend/ssh/managers/simple-reads.ts rename to src/backend/hosts/metrics/managers/simple-reads.ts index 189f36e3..aa5dd2a4 100644 --- a/src/backend/ssh/managers/simple-reads.ts +++ b/src/backend/hosts/metrics/managers/simple-reads.ts @@ -112,7 +112,7 @@ export function registerSimpleReadRoutes( app.get( "/host-metrics/managers/top-memory/:id", validateHostId, - managerHandler(runOnHost, "read", "top_memory", async (client) => { + managerHandler(runOnHost, "connect", "top_memory", async (client) => { const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000); return { processes: parseTopMemory(stdout) }; }), @@ -121,7 +121,7 @@ export function registerSimpleReadRoutes( app.get( "/host-metrics/managers/timers/:id", validateHostId, - managerHandler(runOnHost, "read", "systemd_timers", async (client) => { + managerHandler(runOnHost, "connect", "systemd_timers", async (client) => { const { stdout } = await execCommand(client, TIMERS_CMD, 15000); return { timers: parseTimers(stdout) }; }), @@ -130,7 +130,7 @@ export function registerSimpleReadRoutes( app.get( "/host-metrics/managers/disk-breakdown/:id", validateHostId, - managerHandler(runOnHost, "read", "disk_breakdown", async (client) => { + managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => { const { stdout } = await execCommand(client, DF_CMD, 15000); return { mounts: parseDfMounts(stdout) }; }), diff --git a/src/backend/ssh/managers/ssl.ts b/src/backend/hosts/metrics/managers/ssl.ts similarity index 98% rename from src/backend/ssh/managers/ssl.ts rename to src/backend/hosts/metrics/managers/ssl.ts index 9a17c953..96248bd8 100644 --- a/src/backend/ssh/managers/ssl.ts +++ b/src/backend/hosts/metrics/managers/ssl.ts @@ -153,7 +153,7 @@ export function registerSslRoutes( app.get( "/host-metrics/managers/ssl/:id", validateHostId, - managerHandler(runOnHost, "read", "ssl_list", async (client, host) => { + managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => { const platform = await detectPlatform(client); const certs: CertInfo[] = []; if (platform.hasCertbot) { @@ -187,7 +187,7 @@ export function registerSslRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "ssl_issue", async (client, host, req) => { const body = req.body as Partial; @@ -236,7 +236,7 @@ export function registerSslRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "ssl_renew", async (client, host, req) => { const { client: acmeClient, dryRun } = req.body as { @@ -289,7 +289,7 @@ export function registerSslRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "ssl_revoke", async (client, host, req) => { const { client: acmeClient, name } = req.body as { diff --git a/src/backend/ssh/managers/tailscale.ts b/src/backend/hosts/metrics/managers/tailscale.ts similarity index 97% rename from src/backend/ssh/managers/tailscale.ts rename to src/backend/hosts/metrics/managers/tailscale.ts index 4c63bf1c..59f58eca 100644 --- a/src/backend/ssh/managers/tailscale.ts +++ b/src/backend/hosts/metrics/managers/tailscale.ts @@ -116,7 +116,7 @@ export function registerTailscaleRoutes( app.get( "/host-metrics/managers/tailscale/:id", validateHostId, - managerHandler(runOnHost, "read", "tailscale_read", async (client) => { + managerHandler(runOnHost, "connect", "tailscale_read", async (client) => { const { stdout } = await execCommand(client, PROBE_CMD, 15000); return parseTailscaleData(stdout); }), @@ -154,7 +154,7 @@ export function registerTailscaleRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "tailscale_action", async (client, host, req) => { const { action } = req.body as { action: unknown }; diff --git a/src/backend/ssh/managers/types.ts b/src/backend/hosts/metrics/managers/types.ts similarity index 89% rename from src/backend/ssh/managers/types.ts rename to src/backend/hosts/metrics/managers/types.ts index 05ff6096..09fbee05 100644 --- a/src/backend/ssh/managers/types.ts +++ b/src/backend/hosts/metrics/managers/types.ts @@ -1,5 +1,6 @@ import type { Client } from "ssh2"; import type { RequestHandler } from "express"; +import type { HostAction } from "../../../utils/permission-manager.js"; /** Minimal host shape managers need (includes the decrypted sudo password). */ export interface ManagerHost { @@ -17,7 +18,7 @@ export interface ManagerHost { export type RunOnHost = ( hostId: number, userId: string, - level: "read" | "execute", + level: HostAction, fn: (client: Client, host: ManagerHost) => Promise, ) => Promise; diff --git a/src/backend/ssh/managers/users.ts b/src/backend/hosts/metrics/managers/users.ts similarity index 97% rename from src/backend/ssh/managers/users.ts rename to src/backend/hosts/metrics/managers/users.ts index 4df2278c..764f53ec 100644 --- a/src/backend/ssh/managers/users.ts +++ b/src/backend/hosts/metrics/managers/users.ts @@ -79,7 +79,7 @@ export function registerUserRoutes( app.get( "/host-metrics/managers/users/:id", validateHostId, - managerHandler(runOnHost, "read", "users_list", async (client) => { + managerHandler(runOnHost, "connect", "users_list", async (client) => { const [passwd, groups, sudoers] = await Promise.all([ execCommand(client, READ_USERS_CMD, 15000), execCommand(client, READ_GROUPS_CMD, 15000), @@ -98,7 +98,7 @@ export function registerUserRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "users_action", async (client, host, req) => { const { action, username, group } = req.body as { diff --git a/src/backend/ssh/managers/validation.ts b/src/backend/hosts/metrics/managers/validation.ts similarity index 100% rename from src/backend/ssh/managers/validation.ts rename to src/backend/hosts/metrics/managers/validation.ts diff --git a/src/backend/ssh/managers/wireguard.ts b/src/backend/hosts/metrics/managers/wireguard.ts similarity index 99% rename from src/backend/ssh/managers/wireguard.ts rename to src/backend/hosts/metrics/managers/wireguard.ts index 045b8ef0..018bc6ca 100644 --- a/src/backend/ssh/managers/wireguard.ts +++ b/src/backend/hosts/metrics/managers/wireguard.ts @@ -143,7 +143,7 @@ export function registerWireGuardRoutes( validateHostId, managerHandler( runOnHost, - "read", + "connect", "wireguard_read", async (client, host) => { const result = await execElevated( @@ -194,7 +194,7 @@ export function registerWireGuardRoutes( validateHostId, managerHandler( runOnHost, - "execute", + "connect", "wireguard_action", async (client, host, req) => { const { interface: iface, action } = req.body as { diff --git a/src/backend/ssh/host-metrics-preferences-routes.ts b/src/backend/hosts/metrics/preferences-routes.ts similarity index 79% rename from src/backend/ssh/host-metrics-preferences-routes.ts rename to src/backend/hosts/metrics/preferences-routes.ts index ba9be2e0..3a6e785a 100644 --- a/src/backend/ssh/host-metrics-preferences-routes.ts +++ b/src/backend/hosts/metrics/preferences-routes.ts @@ -1,12 +1,13 @@ import type { Express, RequestHandler } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; -import { statsLogger } from "../utils/logger.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { createCurrentHostMetricsPreferenceRepository } from "../../database/repositories/factory.js"; +import { statsLogger } from "../../utils/logger.js"; import { deriveEnabledWidgets, defaultLayoutFromWidgets, type HostMetricsLayout, -} from "../../types/host-metrics.js"; +} from "../../../types/host-metrics.js"; +import type { HostAction } from "../../utils/permission-manager.js"; interface PrefStatsConfig { enabledWidgets?: string[]; @@ -25,7 +26,7 @@ type HostMetricsPreferencesRoutesDeps = { canAccessHost: ( userId: string, hostId: number, - level: "read" | "execute", + level: HostAction, ) => Promise; }; @@ -88,7 +89,7 @@ export function registerHostMetricsPreferencesRoutes( const userId = (req as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.id), 10); try { - if (!(await canAccessHost(userId, hostId, "read"))) { + if (!(await canAccessHost(userId, hostId, "connect"))) { return res.status(403).json({ error: "No access to this host" }); } const host = await fetchHostById(hostId, userId); @@ -96,12 +97,11 @@ export function registerHostMetricsPreferencesRoutes( return res.status(404).json({ error: "Host not found" }); } - const db = getDb(); - const row = db.$client - .prepare( - "SELECT layout FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?", - ) - .get(userId, hostId) as { layout: string } | undefined; + const row = + await createCurrentHostMetricsPreferenceRepository().findByUserAndHost( + userId, + hostId, + ); if (row?.layout) { try { @@ -167,7 +167,7 @@ export function registerHostMetricsPreferencesRoutes( const userId = (req as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.id), 10); try { - if (!(await canAccessHost(userId, hostId, "read"))) { + if (!(await canAccessHost(userId, hostId, "connect"))) { return res.status(403).json({ error: "No access to this host" }); } const host = await fetchHostById(hostId, userId); @@ -180,29 +180,15 @@ export function registerHostMetricsPreferencesRoutes( return res.status(400).json({ error: "Invalid layout" }); } - const db = getDb(); const now = new Date().toISOString(); const layoutJson = JSON.stringify(layout); - const existing = db.$client - .prepare( - "SELECT id FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?", - ) - .get(userId, hostId) as { id: number } | undefined; - - if (existing) { - db.$client - .prepare( - "UPDATE host_metrics_preferences SET layout = ?, updated_at = ? WHERE id = ?", - ) - .run(layoutJson, now, existing.id); - } else { - db.$client - .prepare( - "INSERT INTO host_metrics_preferences (user_id, host_id, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - ) - .run(userId, hostId, layoutJson, now, now); - } + await createCurrentHostMetricsPreferenceRepository().upsertLayout( + userId, + hostId, + layoutJson, + now, + ); // Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the // user owns. stats_config is plain JSON text (not an encrypted field). @@ -213,11 +199,11 @@ export function registerHostMetricsPreferencesRoutes( ...current, enabledWidgets: deriveEnabledWidgets(layout.slots), }; - db.$client - .prepare( - "UPDATE ssh_data SET stats_config = ? WHERE id = ? AND user_id = ?", - ) - .run(JSON.stringify(merged), hostId, userId); + await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig( + userId, + hostId, + JSON.stringify(merged), + ); } catch (syncErr) { statsLogger.warn("Failed to sync enabledWidgets from layout", { operation: "host_metrics_prefs_sync_widgets", @@ -228,7 +214,6 @@ export function registerHostMetricsPreferencesRoutes( } } - DatabaseSaveTrigger.triggerSave("host_metrics_preferences_updated"); return res.json({ success: true }); } catch (error) { statsLogger.error("Failed to save host metrics preferences", { diff --git a/src/backend/ssh/host-metrics-sessions.ts b/src/backend/hosts/metrics/sessions.ts similarity index 97% rename from src/backend/ssh/host-metrics-sessions.ts rename to src/backend/hosts/metrics/sessions.ts index fb9aa71f..c23b6f35 100644 --- a/src/backend/ssh/host-metrics-sessions.ts +++ b/src/backend/hosts/metrics/sessions.ts @@ -1,5 +1,5 @@ import type { Client, ConnectConfig } from "ssh2"; -import { statsLogger } from "../utils/logger.js"; +import { statsLogger } from "../../utils/logger.js"; export interface MetricsSession { client: Client; diff --git a/src/backend/ssh/host-metrics-settings-routes.ts b/src/backend/hosts/metrics/settings-routes.ts similarity index 73% rename from src/backend/ssh/host-metrics-settings-routes.ts rename to src/backend/hosts/metrics/settings-routes.ts index 453485e9..f7f64ace 100644 --- a/src/backend/ssh/host-metrics-settings-routes.ts +++ b/src/backend/hosts/metrics/settings-routes.ts @@ -1,6 +1,6 @@ import type { Express, RequestHandler } from "express"; -import { getDb } from "../database/db/index.js"; -import { statsLogger } from "../utils/logger.js"; +import { statsLogger } from "../../utils/logger.js"; +import { createCurrentSettingsRepository } from "../../database/repositories/factory.js"; type HostMetricsSettingsConfig = { statusCheckInterval: number; @@ -13,6 +13,12 @@ type HostMetricsSettingsRoutesDeps = { refreshAllPolling: () => Promise; }; +function isMissingSettingsTable(error: unknown): boolean { + return ( + error instanceof Error && error.message.includes("no such table: settings") + ); +} + export function registerHostMetricsSettingsRoutes( app: Express, { @@ -36,17 +42,23 @@ export function registerHostMetricsSettingsRoutes( */ app.get("/global-settings", requireAdmin, async (_req, res) => { try { - const db = getDb(); + const settings = createCurrentSettingsRepository(); + const statusValue = await settings.get("global_status_check_interval"); + const metricsValue = await settings.get("global_metrics_interval"); - try { - db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); - } catch (tableError) { + res.json({ + statusCheckInterval: statusValue + ? parseInt(statusValue, 10) + : DEFAULT_STATS_CONFIG.statusCheckInterval, + metricsInterval: metricsValue + ? parseInt(metricsValue, 10) + : DEFAULT_STATS_CONFIG.metricsInterval, + }); + } catch (error) { + if (isMissingSettingsTable(error)) { statsLogger.warn("Settings table does not exist, using defaults", { operation: "global_settings_table_check", - error: - tableError instanceof Error - ? tableError.message - : String(tableError), + error: error.message, }); return res.json({ statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval, @@ -54,26 +66,6 @@ export function registerHostMetricsSettingsRoutes( }); } - const statusRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_status_check_interval'", - ) - .get() as { value: string } | undefined; - const metricsRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_metrics_interval'", - ) - .get() as { value: string } | undefined; - - res.json({ - statusCheckInterval: statusRow - ? parseInt(statusRow.value, 10) - : DEFAULT_STATS_CONFIG.statusCheckInterval, - metricsInterval: metricsRow - ? parseInt(metricsRow.value, 10) - : DEFAULT_STATS_CONFIG.metricsInterval, - }); - } catch (error) { statsLogger.error("Failed to fetch global settings", { operation: "global_settings_fetch_error", error: error instanceof Error ? error.message : String(error), @@ -133,40 +125,16 @@ export function registerHostMetricsSettingsRoutes( } try { - const db = getDb(); - - try { - db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); - } catch (tableError) { - statsLogger.error( - "Settings table does not exist, cannot save settings", - { - operation: "global_settings_table_check", - error: - tableError instanceof Error - ? tableError.message - : String(tableError), - }, - ); - return res.status(500).json({ - error: - "Database settings table is missing. Please check database initialization.", - }); - } + const settings = createCurrentSettingsRepository(); if (statusCheckInterval !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)", - ) - .run(String(statusCheckInterval)); + await settings.set( + "global_status_check_interval", + String(statusCheckInterval), + ); } if (metricsInterval !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)", - ) - .run(String(metricsInterval)); + await settings.set("global_metrics_interval", String(metricsInterval)); } await refreshAllPolling(); @@ -176,6 +144,20 @@ export function registerHostMetricsSettingsRoutes( message: "Settings updated and polling refreshed", }); } catch (error) { + if (isMissingSettingsTable(error)) { + statsLogger.error( + "Settings table does not exist, cannot save settings", + { + operation: "global_settings_table_check", + error: error.message, + }, + ); + return res.status(500).json({ + error: + "Database settings table is missing. Please check database initialization.", + }); + } + statsLogger.error("Failed to save global settings", { operation: "global_settings_save_error", error: error instanceof Error ? error.message : String(error), @@ -200,15 +182,12 @@ export function registerHostMetricsSettingsRoutes( * 403: * description: Requires admin privileges. */ - app.get("/global-settings/history", requireAdmin, (_req, res) => { + app.get("/global-settings/history", requireAdmin, async (_req, res) => { try { - const db = getDb(); - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'metrics_history_retention_days'", - ) - .get() as { value: string } | undefined; - const days = row ? parseInt(row.value, 10) : 7; + const value = await createCurrentSettingsRepository().get( + "metrics_history_retention_days", + ); + const days = value ? parseInt(value, 10) : 7; res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days }); } catch (error) { statsLogger.error("Failed to fetch history retention setting", { @@ -247,7 +226,7 @@ export function registerHostMetricsSettingsRoutes( * 403: * description: Requires admin privileges. */ - app.post("/global-settings/history", requireAdmin, (req, res) => { + app.post("/global-settings/history", requireAdmin, async (req, res) => { const { metricsHistoryRetentionDays } = req.body; if ( typeof metricsHistoryRetentionDays !== "number" || @@ -259,12 +238,10 @@ export function registerHostMetricsSettingsRoutes( }); } try { - const db = getDb(); - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('metrics_history_retention_days', ?)", - ) - .run(String(Math.floor(metricsHistoryRetentionDays))); + await createCurrentSettingsRepository().set( + "metrics_history_retention_days", + String(Math.floor(metricsHistoryRetentionDays)), + ); res.json({ success: true }); } catch (error) { statsLogger.error("Failed to save history retention setting", { diff --git a/src/backend/ssh/host-metrics-state.ts b/src/backend/hosts/metrics/state.ts similarity index 75% rename from src/backend/ssh/host-metrics-state.ts rename to src/backend/hosts/metrics/state.ts index f1140bd3..7a2e4ebf 100644 --- a/src/backend/ssh/host-metrics-state.ts +++ b/src/backend/hosts/metrics/state.ts @@ -250,6 +250,88 @@ class PollingBackoff { } } +/** + * Limits how many async jobs run at once. Extra callers wait in FIFO order. + * Used to stop host-metrics status/metrics polls from stampeding under load. + */ +export class ConcurrentLimiter { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly maxConcurrent: number) { + if (maxConcurrent < 1) { + throw new Error("maxConcurrent must be >= 1"); + } + } + + get activeCount(): number { + return this.active; + } + + get pendingCount(): number { + return this.waiters.length; + } + + async run(fn: () => Promise): Promise { + if (this.active >= this.maxConcurrent) { + await new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + this.active += 1; + try { + return await fn(); + } finally { + this.active -= 1; + const next = this.waiters.shift(); + if (next) next(); + } + } +} + +/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */ +export class HostPollCache { + private cache = new Map< + number, + { host: THost; userId: string; expiresAt: number } + >(); + + constructor(private readonly ttlMs = 30_000) {} + + get(hostId: number, userId: string): THost | null { + const entry = this.cache.get(hostId); + if (!entry) return null; + if (entry.userId !== userId || Date.now() >= entry.expiresAt) { + this.cache.delete(hostId); + return null; + } + return entry.host; + } + + set(hostId: number, userId: string, host: THost): void { + this.cache.set(hostId, { + host, + userId, + expiresAt: Date.now() + this.ttlMs, + }); + } + + invalidate(hostId?: number): void { + if (hostId === undefined) { + this.cache.clear(); + return; + } + this.cache.delete(hostId); + } +} + +/** TCP status checks are cheap relative to SSH metrics collection. */ +export const statusPollLimiter = new ConcurrentLimiter(20); +/** SSH metrics execs are expensive; keep concurrency tight. */ +export const metricsPollLimiter = new ConcurrentLimiter(5); +export const hostPollCache = new HostPollCache(30_000); + export const requestQueue = new RequestQueue(); export const metricsCache = new MetricsCache(); export const authFailureTracker = new AuthFailureTracker(); diff --git a/src/backend/ssh/host-metrics-viewer-routes.ts b/src/backend/hosts/metrics/viewer-routes.ts similarity index 96% rename from src/backend/ssh/host-metrics-viewer-routes.ts rename to src/backend/hosts/metrics/viewer-routes.ts index 505c4a3c..0467585f 100644 --- a/src/backend/ssh/host-metrics-viewer-routes.ts +++ b/src/backend/hosts/metrics/viewer-routes.ts @@ -1,7 +1,7 @@ import type { Express } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { statsLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { statsLogger } from "../../utils/logger.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; type ViewerStatsConfig = { metricsEnabled: boolean; @@ -70,7 +70,7 @@ export function registerHostMetricsViewerRoutes< const { viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -129,7 +129,7 @@ export function registerHostMetricsViewerRoutes< const { hostId } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -259,7 +259,7 @@ export function registerHostMetricsViewerRoutes< const { hostId, viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", diff --git a/src/backend/ssh/widgets/common-utils.ts b/src/backend/hosts/metrics/widgets/common-utils.ts similarity index 100% rename from src/backend/ssh/widgets/common-utils.ts rename to src/backend/hosts/metrics/widgets/common-utils.ts diff --git a/src/backend/ssh/widgets/cpu-collector.ts b/src/backend/hosts/metrics/widgets/cpu-collector.ts similarity index 100% rename from src/backend/ssh/widgets/cpu-collector.ts rename to src/backend/hosts/metrics/widgets/cpu-collector.ts diff --git a/src/backend/ssh/widgets/disk-collector.ts b/src/backend/hosts/metrics/widgets/disk-collector.ts similarity index 100% rename from src/backend/ssh/widgets/disk-collector.ts rename to src/backend/hosts/metrics/widgets/disk-collector.ts diff --git a/src/backend/ssh/widgets/firewall-collector.ts b/src/backend/hosts/metrics/widgets/firewall-collector.ts similarity index 99% rename from src/backend/ssh/widgets/firewall-collector.ts rename to src/backend/hosts/metrics/widgets/firewall-collector.ts index 1043ee39..10d816ef 100644 --- a/src/backend/ssh/widgets/firewall-collector.ts +++ b/src/backend/hosts/metrics/widgets/firewall-collector.ts @@ -4,7 +4,7 @@ import type { FirewallMetrics, FirewallChain, FirewallRule, -} from "../../../types/stats-widgets.js"; +} from "../../../../types/stats-widgets.js"; function parseIptablesRule(line: string): FirewallRule | null { if (!line.startsWith("-A ")) return null; diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/hosts/metrics/widgets/login-stats-collector.ts similarity index 100% rename from src/backend/ssh/widgets/login-stats-collector.ts rename to src/backend/hosts/metrics/widgets/login-stats-collector.ts diff --git a/src/backend/ssh/widgets/memory-collector.ts b/src/backend/hosts/metrics/widgets/memory-collector.ts similarity index 100% rename from src/backend/ssh/widgets/memory-collector.ts rename to src/backend/hosts/metrics/widgets/memory-collector.ts diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/hosts/metrics/widgets/network-collector.ts similarity index 100% rename from src/backend/ssh/widgets/network-collector.ts rename to src/backend/hosts/metrics/widgets/network-collector.ts diff --git a/src/backend/ssh/widgets/ports-collector.ts b/src/backend/hosts/metrics/widgets/ports-collector.ts similarity index 98% rename from src/backend/ssh/widgets/ports-collector.ts rename to src/backend/hosts/metrics/widgets/ports-collector.ts index 7406909e..15713761 100644 --- a/src/backend/ssh/widgets/ports-collector.ts +++ b/src/backend/hosts/metrics/widgets/ports-collector.ts @@ -3,7 +3,7 @@ import { execCommand } from "./common-utils.js"; import type { PortsMetrics, ListeningPort, -} from "../../../types/stats-widgets.js"; +} from "../../../../types/stats-widgets.js"; function parseSsOutput(output: string): ListeningPort[] { const ports: ListeningPort[] = []; diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/hosts/metrics/widgets/processes-collector.ts similarity index 100% rename from src/backend/ssh/widgets/processes-collector.ts rename to src/backend/hosts/metrics/widgets/processes-collector.ts diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/hosts/metrics/widgets/system-collector.ts similarity index 100% rename from src/backend/ssh/widgets/system-collector.ts rename to src/backend/hosts/metrics/widgets/system-collector.ts diff --git a/src/backend/ssh/widgets/temperature-collector.ts b/src/backend/hosts/metrics/widgets/temperature-collector.ts similarity index 100% rename from src/backend/ssh/widgets/temperature-collector.ts rename to src/backend/hosts/metrics/widgets/temperature-collector.ts diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/hosts/metrics/widgets/uptime-collector.ts similarity index 100% rename from src/backend/ssh/widgets/uptime-collector.ts rename to src/backend/hosts/metrics/widgets/uptime-collector.ts diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/hosts/opkssh-auth.ts similarity index 91% rename from src/backend/ssh/opkssh-auth.ts rename to src/backend/hosts/opkssh-auth.ts index b2890dc0..1988bc96 100644 --- a/src/backend/ssh/opkssh-auth.ts +++ b/src/backend/hosts/opkssh-auth.ts @@ -3,15 +3,13 @@ import { randomUUID } from "crypto"; import { WebSocket } from "ws"; import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js"; import { sshLogger } from "../utils/logger.js"; -import { getDb } from "../database/db/index.js"; -import { opksshTokens } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { promises as fs } from "fs"; import path from "path"; import axios from "axios"; -import yaml from "js-yaml"; +import { load as loadYaml } from "js-yaml"; const AUTH_TIMEOUT = 60 * 1000; @@ -131,7 +129,7 @@ async function checkOPKConfigExists(): Promise<{ let providers: ProviderRedirectInfo[] = []; try { - const parsed = yaml.load(content) as { + const parsed = loadYaml(content) as { providers?: Array<{ alias?: string; issuer?: string; @@ -649,13 +647,10 @@ function handleOPKSSHOutput(requestId: string, output: string): void { async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { try { - const db = getDb(); - const userCrypto = UserCrypto.getInstance(); - const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 24); - const userDataKey = userCrypto.getUserDataKey(session.userId); + const userDataKey = DataCrypto.getUserDataKey(session.userId); if (!userDataKey) { throw new Error("User data key not found"); } @@ -675,32 +670,17 @@ async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { "private_key", ); - await db - .insert(opksshTokens) - .values({ - userId: session.userId, - hostId: session.hostId, - sshCert: encryptedCert, - privateKey: encryptedKey, - email: session.identity.email, - sub: session.identity.sub, - issuer: session.identity.issuer, - audience: session.identity.audience, - expiresAt: expiresAt.toISOString(), - }) - .onConflictDoUpdate({ - target: [opksshTokens.userId, opksshTokens.hostId], - set: { - sshCert: encryptedCert, - privateKey: encryptedKey, - email: session.identity.email, - sub: session.identity.sub, - issuer: session.identity.issuer, - audience: session.identity.audience, - expiresAt: expiresAt.toISOString(), - createdAt: new Date().toISOString(), - }, - }); + await createCurrentOpksshTokenRepository().upsert({ + userId: session.userId, + hostId: session.hostId, + sshCert: encryptedCert, + privateKey: encryptedKey, + email: session.identity.email, + sub: session.identity.sub, + issuer: session.identity.issuer, + audience: session.identity.audience, + expiresAt: expiresAt.toISOString(), + }); session.status = "completed"; session.ws.send( @@ -733,33 +713,21 @@ export async function getOPKSSHToken( hostId: number, ): Promise<{ sshCert: string; privateKey: string } | null> { try { - const db = getDb(); - const token = await db - .select() - .from(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .limit(1); + const repository = createCurrentOpksshTokenRepository(); + const tokenData = await repository.findByUserAndHost(userId, hostId); - if (!token || token.length === 0) { + if (!tokenData) { return null; } - const tokenData = token[0]; const expiresAt = new Date(tokenData.expiresAt); if (expiresAt < new Date()) { - await db - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await repository.deleteByUserAndHost(userId, hostId); return null; } - const userCrypto = UserCrypto.getInstance(); - const userDataKey = userCrypto.getUserDataKey(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } @@ -778,12 +746,7 @@ export async function getOPKSSHToken( "private_key", ); - await db - .update(opksshTokens) - .set({ lastUsed: new Date().toISOString() }) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await repository.updateLastUsed(userId, hostId); return { sshCert: decryptedCert, @@ -799,12 +762,10 @@ export async function deleteOPKSSHToken( userId: string, hostId: number, ): Promise { - const db = getDb(); - await db - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await createCurrentOpksshTokenRepository().deleteByUserAndHost( + userId, + hostId, + ); } export async function invalidateOPKSSHToken( @@ -813,12 +774,10 @@ export async function invalidateOPKSSHToken( reason: string, ): Promise { try { - const db = getDb(); - await db - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await createCurrentOpksshTokenRepository().deleteByUserAndHost( + userId, + hostId, + ); } catch (error) { sshLogger.error(`Failed to invalidate OPKSSH token`, { userId, diff --git a/src/backend/ssh/opkssh-cert-auth.ts b/src/backend/hosts/opkssh-cert-auth.ts similarity index 100% rename from src/backend/ssh/opkssh-cert-auth.ts rename to src/backend/hosts/opkssh-cert-auth.ts diff --git a/src/backend/serial/serial.ts b/src/backend/hosts/serial.ts similarity index 96% rename from src/backend/serial/serial.ts rename to src/backend/hosts/serial.ts index f491c068..7868c2eb 100644 --- a/src/backend/serial/serial.ts +++ b/src/backend/hosts/serial.ts @@ -1,7 +1,7 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; import { SerialPort } from "serialport"; import { AuthManager } from "../utils/auth-manager.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { sshLogger } from "../utils/logger.js"; interface SerialConnectData { @@ -18,7 +18,6 @@ interface WebSocketMessage { } const authManager = AuthManager.getInstance(); -const userCrypto = UserCrypto.getInstance(); const wss = new WebSocketServer({ port: 30011 }); @@ -64,7 +63,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - const dataKey = userCrypto.getUserDataKey(userId); + const dataKey = DataCrypto.getUserDataKey(userId); if (!dataKey) { ws.send(JSON.stringify({ type: "error", data: "Data locked" })); ws.close(1008, "Data access required"); diff --git a/src/backend/hosts/ssh-connection-pool.ts b/src/backend/hosts/ssh-connection-pool.ts new file mode 100644 index 00000000..0f0691b7 --- /dev/null +++ b/src/backend/hosts/ssh-connection-pool.ts @@ -0,0 +1,300 @@ +import { Client } from "ssh2"; +import { sshLogger } from "../utils/logger.js"; + +interface PooledConnection { + client: Client; + lastUsed: number; + inUse: boolean; + hostKey: string; +} + +interface ConnectionWaiter { + resolve: (client: Client) => void; + reject: (error: Error) => void; + factory: () => Promise; + timer: NodeJS.Timeout; +} + +const DEFAULT_MAX_CONNECTIONS_PER_HOST = 3; +const DEFAULT_MAX_WAIT_MS = 30_000; +const IDLE_MAX_AGE_MS = 10 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 2 * 60 * 1000; + +class SSHConnectionPool { + private connections = new Map(); + private waiters = new Map(); + private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST; + private maxWaitMs = DEFAULT_MAX_WAIT_MS; + private cleanupInterval: NodeJS.Timeout; + + constructor() { + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, CLEANUP_INTERVAL_MS); + } + + private isConnectionHealthy(client: Client): boolean { + try { + const sock = ( + client as unknown as { + _sock?: { destroyed?: boolean; writable?: boolean }; + } + )._sock; + if (sock && (sock.destroyed || !sock.writable)) { + return false; + } + return true; + } catch { + return false; + } + } + + private removeUnhealthy( + key: string, + connections: PooledConnection[], + target: PooledConnection, + ): PooledConnection[] { + sshLogger.warn("Removing unhealthy connection from pool", { + operation: "pool_remove_dead", + hostKey: key, + }); + try { + target.client.end(); + } catch { + // expected + } + const filtered = connections.filter((c) => c !== target); + this.connections.set(key, filtered); + return filtered; + } + + private async createPooledClient( + key: string, + factory: () => Promise, + existing: PooledConnection[], + ): Promise { + const client = await factory(); + const pooled: PooledConnection = { + client, + lastUsed: Date.now(), + inUse: true, + hostKey: key, + }; + existing.push(pooled); + this.connections.set(key, existing); + + client.on("end", () => { + this.removeConnection(key, client); + }); + client.on("close", () => { + this.removeConnection(key, client); + }); + + return client; + } + + private enqueueWaiter( + key: string, + factory: () => Promise, + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const queue = this.waiters.get(key); + if (queue) { + const idx = queue.findIndex((w) => w.timer === timer); + if (idx >= 0) queue.splice(idx, 1); + if (queue.length === 0) this.waiters.delete(key); + } + const err = new Error( + `SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`, + ); + sshLogger.warn("Connection pool wait timeout", { + operation: "pool_wait_timeout", + hostKey: key, + maxWaitMs: this.maxWaitMs, + }); + reject(err); + }, this.maxWaitMs); + + const waiter: ConnectionWaiter = { resolve, reject, factory, timer }; + const queue = this.waiters.get(key) || []; + queue.push(waiter); + this.waiters.set(key, queue); + }); + } + + /** Hand a free connection (or new slot) to the next waiter, if any. */ + private wakeWaiter(key: string): void { + const queue = this.waiters.get(key); + if (!queue || queue.length === 0) return; + + let connections = this.connections.get(key) || []; + const available = connections.find((conn) => !conn.inUse); + + if (available) { + if (!this.isConnectionHealthy(available.client)) { + connections = this.removeUnhealthy(key, connections, available); + // Fall through — may create or wait again. + } else { + const waiter = queue.shift()!; + if (queue.length === 0) this.waiters.delete(key); + else this.waiters.set(key, queue); + clearTimeout(waiter.timer); + available.inUse = true; + available.lastUsed = Date.now(); + waiter.resolve(available.client); + return; + } + } + + if (connections.length < this.maxConnectionsPerHost) { + const waiter = queue.shift()!; + if (queue.length === 0) this.waiters.delete(key); + else this.waiters.set(key, queue); + clearTimeout(waiter.timer); + this.createPooledClient(key, waiter.factory, connections) + .then(waiter.resolve) + .catch(waiter.reject); + return; + } + } + + private rejectWaiters(key: string, reason: string): void { + const queue = this.waiters.get(key); + if (!queue) return; + this.waiters.delete(key); + for (const waiter of queue) { + clearTimeout(waiter.timer); + waiter.reject(new Error(reason)); + } + } + + async getConnection( + key: string, + factory: () => Promise, + ): Promise { + let connections = this.connections.get(key) || []; + + const available = connections.find((conn) => !conn.inUse); + if (available) { + if (!this.isConnectionHealthy(available.client)) { + connections = this.removeUnhealthy(key, connections, available); + } else { + available.inUse = true; + available.lastUsed = Date.now(); + return available.client; + } + } + + if (connections.length < this.maxConnectionsPerHost) { + return this.createPooledClient(key, factory, connections); + } + + return this.enqueueWaiter(key, factory); + } + + releaseConnection(key: string, client: Client): void { + const connections = this.connections.get(key) || []; + const pooled = connections.find((conn) => conn.client === client); + if (pooled) { + pooled.inUse = false; + pooled.lastUsed = Date.now(); + this.wakeWaiter(key); + } + } + + private removeConnection(key: string, client: Client): void { + const connections = this.connections.get(key); + if (!connections) return; + const filtered = connections.filter((c) => c.client !== client); + if (filtered.length === 0) { + this.connections.delete(key); + } else { + this.connections.set(key, filtered); + } + // A slot or idle connection may now be free for waiters. + this.wakeWaiter(key); + } + + clearKeyConnections(key: string): void { + const connections = this.connections.get(key) || []; + for (const conn of connections) { + try { + conn.client.end(); + } catch { + // expected + } + } + this.connections.delete(key); + this.rejectWaiters(key, `SSH connection pool cleared for ${key}`); + } + + private cleanup(): void { + const now = Date.now(); + + for (const [hostKey, connections] of this.connections.entries()) { + const activeConnections = connections.filter((conn) => { + if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) { + try { + conn.client.end(); + } catch { + // expected + } + return false; + } + if (!this.isConnectionHealthy(conn.client)) { + try { + conn.client.end(); + } catch { + // expected + } + return false; + } + return true; + }); + + if (activeConnections.length === 0) { + this.connections.delete(hostKey); + } else { + this.connections.set(hostKey, activeConnections); + } + this.wakeWaiter(hostKey); + } + } + + clearAllConnections(): void { + for (const key of [...this.waiters.keys()]) { + this.rejectWaiters(key, "SSH connection pool destroyed"); + } + for (const connections of this.connections.values()) { + for (const conn of connections) { + try { + conn.client.end(); + } catch { + // expected + } + } + } + this.connections.clear(); + } + + destroy(): void { + clearInterval(this.cleanupInterval); + this.clearAllConnections(); + } +} + +export const connectionPool = new SSHConnectionPool(); + +export async function withConnection( + key: string, + factory: () => Promise, + fn: (client: Client) => Promise, +): Promise { + const client = await connectionPool.getConnection(key, factory); + try { + return await fn(client); + } finally { + connectionPool.releaseConnection(key, client); + } +} diff --git a/src/backend/hosts/ssh-dns.ts b/src/backend/hosts/ssh-dns.ts new file mode 100644 index 00000000..8b016ee9 --- /dev/null +++ b/src/backend/hosts/ssh-dns.ts @@ -0,0 +1,82 @@ +import dns from "dns/promises"; +import net from "net"; + +export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500]; + +type Lookup = typeof dns.lookup; +type Sleep = (ms: number) => Promise; +type SshConnectConfigHost = { + host?: unknown; +}; + +const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function isRetriableDnsError(error: unknown): boolean { + const err = error as { code?: unknown; message?: unknown }; + return ( + err.code === "EAI_AGAIN" || + (typeof err.message === "string" && err.message.includes("EAI_AGAIN")) + ); +} + +export function shouldResolveBeforeSshConnect(host: string): boolean { + const normalized = host.replace(/^\[|\]$/g, "").trim(); + if (!normalized) return false; + return net.isIP(normalized) === 0; +} + +export async function resolveHostForSshConnect( + host: string, + lookup: Lookup = dns.lookup, + retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS, + wait: Sleep = sleep, +): Promise<{ host: string; resolvedAddress?: string; attempts: number }> { + const normalized = host.replace(/^\[|\]$/g, "").trim(); + if (!shouldResolveBeforeSshConnect(normalized)) { + return { host: normalized || host, attempts: 0 }; + } + + for (let attempt = 0; ; attempt += 1) { + try { + const result = await lookup(normalized); + return { + host: result.address, + resolvedAddress: result.address, + attempts: attempt + 1, + }; + } catch (error) { + if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) { + throw error; + } + await wait(retryDelaysMs[attempt]); + } + } +} + +export async function resolveSshConnectConfigHost< + T extends SshConnectConfigHost, +>( + config: T, + lookup: Lookup = dns.lookup, + retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS, + wait: Sleep = sleep, +): Promise< + T & { host?: unknown; resolvedHost?: string; originalHost?: string } +> { + if (typeof config.host !== "string") return config; + + const originalHost = config.host; + const resolution = await resolveHostForSshConnect( + originalHost, + lookup, + retryDelaysMs, + wait, + ); + if (!resolution.resolvedAddress) return config; + + config.host = resolution.host; + return Object.assign(config, { + originalHost, + resolvedHost: resolution.resolvedAddress, + }); +} diff --git a/src/backend/ssh/terminal-auth-helpers.ts b/src/backend/hosts/terminal-auth-helpers.ts similarity index 55% rename from src/backend/ssh/terminal-auth-helpers.ts rename to src/backend/hosts/terminal-auth-helpers.ts index 786a6a3a..8bbf3cc8 100644 --- a/src/backend/ssh/terminal-auth-helpers.ts +++ b/src/backend/hosts/terminal-auth-helpers.ts @@ -8,6 +8,19 @@ import ssh2Pkg, { } from "ssh2"; const { BaseAgent } = ssh2Pkg; +const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000; + +type Sleep = (ms: number) => Promise; + +type PortKnockingOptions = { + tcpTimeoutMs?: number; + udpTimeoutMs?: number; + createTcpSocket?: () => net.Socket; + createUdpSocket?: () => dgram.Socket; + wait?: Sleep; +}; + +const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export class MemoryAgent extends BaseAgent { private key: ParsedKey; @@ -45,20 +58,10 @@ export class MemoryAgent extends BaseAgent { } export async function resolveAgentSocket( - terminalConfig: Record | string | undefined, + terminalConfig: Record | undefined, ): Promise<{ socketPath: string } | { error: string }> { - let parsedConfig: Record | undefined; - if (typeof terminalConfig === "string") { - try { - parsedConfig = JSON.parse(terminalConfig) as Record; - } catch { - return { error: "Invalid terminal configuration for SSH agent auth." }; - } - } else { - parsedConfig = terminalConfig; - } const explicit = ( - parsedConfig?.agentSocketPath as string | undefined + terminalConfig?.agentSocketPath as string | undefined )?.trim(); const resolved = explicit || process.env.SSH_AUTH_SOCK; @@ -84,7 +87,7 @@ export async function resolveAgentSocket( export async function applyAgentAuth( connectConfig: Record, - terminalConfig: Record | string | undefined, + terminalConfig: Record | undefined, ): Promise<{ socketPath: string } | { error: string }> { const result = await resolveAgentSocket(terminalConfig); if ("error" in result) return result; @@ -97,34 +100,59 @@ export async function applyAgentAuth( export async function performPortKnocking( host: string, sequence: Array<{ port: number; protocol?: string; delay?: number }>, + options: PortKnockingOptions = {}, ): Promise { + const createTcpSocket = options.createTcpSocket ?? (() => new net.Socket()); + const createUdpSocket = + options.createUdpSocket ?? (() => dgram.createSocket("udp4")); + const wait = options.wait ?? sleep; + const tcpTimeoutMs = options.tcpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS; + const udpTimeoutMs = options.udpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS; + for (const knock of sequence) { - const protocol = knock.protocol || "tcp"; - const delay = knock.delay ?? 100; + const port = Number(knock.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) continue; + + const protocol = (knock.protocol || "tcp").toLowerCase(); + const delay = Number(knock.delay ?? 100); await new Promise((resolve) => { if (protocol === "udp") { - const client = dgram.createSocket("udp4"); - client.send(Buffer.alloc(0), knock.port, host, () => { + const client = createUdpSocket(); + let settled = false; + const timeout = setTimeout(() => finish(), udpTimeoutMs); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); client.close(); resolve(); + }; + client.once("error", finish); + client.send(Buffer.alloc(0), port, host, () => { + finish(); }); } else { - const socket = new net.Socket(); - socket.once("connect", () => { + const socket = createTcpSocket(); + let settled = false; + const timeout = setTimeout(() => finish(), tcpTimeoutMs); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.removeAllListeners("connect"); + socket.removeAllListeners("error"); socket.destroy(); resolve(); - }); - socket.once("error", () => { - socket.destroy(); - resolve(); - }); - socket.connect(knock.port, host); + }; + socket.once("connect", finish); + socket.once("error", finish); + socket.connect(port, host); } }); if (delay > 0) { - await new Promise((r) => setTimeout(r, delay)); + await wait(delay); } } } diff --git a/src/backend/ssh/terminal.ts b/src/backend/hosts/terminal/index.ts similarity index 94% rename from src/backend/ssh/terminal.ts rename to src/backend/hosts/terminal/index.ts index 31cbb7d6..f23b2a10 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/hosts/terminal/index.ts @@ -5,38 +5,36 @@ import ssh2Pkg, { type PseudoTtyOptions, } from "ssh2"; const { Client, utils: ssh2Utils } = ssh2Pkg; -import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js"; +import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js"; import axios from "axios"; -import { getDb } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { sshLogger, authLogger } from "../utils/logger.js"; -import { logAudit } from "../utils/audit-logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; +import { sshLogger, authLogger } from "../../utils/logger.js"; +import { logAudit } from "../../utils/audit-logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import { SSHAuthManager } from "./auth-manager.js"; -import type { ProxyNode } from "../../types/index.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { createJumpHostChain } from "./terminal-jump-hosts.js"; -import { sessionManager } from "./terminal-session-manager.js"; +} from "../../utils/socks5-helper.js"; +import { SSHAuthManager } from "../auth-manager.js"; +import type { ProxyNode } from "../../../types/index.js"; +import { SSHHostKeyVerifier } from "../host-key-verifier.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; +import { sessionManager } from "./session-manager.js"; import { detectTmux, attachOrCreateTmuxSession, waitForTmuxSession, -} from "./tmux-helper.js"; +} from "../tmux/helper.js"; import { - applyAgentAuth, MemoryAgent, performPortKnocking, -} from "./terminal-auth-helpers.js"; -import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; -import { triggerLoginAlert } from "../utils/alert-trigger.js"; + resolveAgentSocket, +} from "../terminal-auth-helpers.js"; +import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { triggerLoginAlert } from "../../utils/alert-trigger.js"; +import { isRetriableDnsError, resolveHostForSshConnect } from "../ssh-dns.js"; interface ConnectToHostData { cols: number; @@ -55,8 +53,6 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; - passwordFallbackOnly?: boolean; - passwordFallbackAttempted?: boolean; jumpHosts?: Array<{ hostId: number }>; useSocks5?: boolean; socks5Host?: string; @@ -75,6 +71,8 @@ interface ConnectToHostData { [key: string]: unknown; }; enableSessionLogging?: boolean; + /** When true, ignore key material and force password auth (fallback path). */ + passwordFallbackOnly?: boolean; }; initialPath?: string; executeCommand?: string; @@ -100,7 +98,6 @@ interface WebSocketMessage { } const authManager = AuthManager.getInstance(); -const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); @@ -160,7 +157,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - const dataKey = userCrypto.getUserDataKey(userId); + const dataKey = DataCrypto.getUserDataKey(userId); if (!dataKey) { ws.send( JSON.stringify({ @@ -270,7 +267,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } ws.on("message", async (msg: RawData) => { - const currentDataKey = userCrypto.getUserDataKey(userId); + const currentDataKey = DataCrypto.getUserDataKey(userId); if (!currentDataKey) { ws.send( JSON.stringify({ @@ -538,6 +535,9 @@ wss.on("connection", async (ws: WebSocket, req) => { case "input": { const inputData = data as string; + if (currentSessionId) { + sessionManager.bufferInput(currentSessionId, inputData); + } const inputStream = sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; if (inputStream) { @@ -781,16 +781,15 @@ wss.on("connection", async (ws: WebSocket, req) => { case "opkssh_start_auth": { const opksshData = data as { hostId: number }; try { - const { startOPKSSHAuth } = await import("./opkssh-auth.js"); - const { getRequestBaseUrl } = - await import("../utils/request-origin.js"); - const db = getDb(); - const hostRow = await db - .select() - .from(hosts) - .where(eq(hosts.id, opksshData.hostId)) - .limit(1); - if (!hostRow || hostRow.length === 0) { + const { startOPKSSHAuth } = await import("../opkssh-auth.js"); + const { getRequestOrigin } = + await import("../../utils/request-origin.js"); + const host = + await createCurrentHostResolutionRepository().findHostById( + opksshData.hostId, + userId, + ); + if (!host) { sshLogger.error( `Host ${opksshData.hostId} not found for OPKSSH auth`, { @@ -808,8 +807,8 @@ wss.on("connection", async (ws: WebSocket, req) => { ); break; } - const hostname = hostRow[0].name || hostRow[0].ip; - const requestOrigin = getRequestBaseUrl(req); + const hostname = host.name || host.ip; + const requestOrigin = getRequestOrigin(req); await startOPKSSHAuth( userId, opksshData.hostId, @@ -837,7 +836,7 @@ wss.on("connection", async (ws: WebSocket, req) => { case "opkssh_cancel": { const cancelData = data as { requestId: string }; try { - const { cancelAuthSession } = await import("./opkssh-auth.js"); + const { cancelAuthSession } = await import("../opkssh-auth.js"); cancelAuthSession(cancelData.requestId); resetConnectionState(); } catch (error) { @@ -899,10 +898,13 @@ wss.on("connection", async (ws: WebSocket, req) => { const vaultData = data as { hostId: number }; try { const { loadVaultProfileForHost, startVaultAuth } = - await import("./vault-oidc-auth.js"); - const { getRequestBaseUrl } = - await import("../utils/request-origin.js"); - const profile = await loadVaultProfileForHost(vaultData.hostId); + await import("../vault-oidc-auth.js"); + const { getRequestOrigin } = + await import("../../utils/request-origin.js"); + const profile = await loadVaultProfileForHost( + vaultData.hostId, + userId, + ); if (!profile) { ws.send( JSON.stringify({ @@ -913,7 +915,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); break; } - const requestOrigin = getRequestBaseUrl(req); + const requestOrigin = getRequestOrigin(req); await startVaultAuth( userId, vaultData.hostId, @@ -945,7 +947,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const cancelData = data as { hostId: number }; try { const { cancelVaultAuthByHost } = - await import("./vault-oidc-auth.js"); + await import("../vault-oidc-auth.js"); cancelVaultAuthByHost(userId, cancelData.hostId); resetConnectionState(); } catch (error) { @@ -1105,6 +1107,9 @@ wss.on("connection", async (ws: WebSocket, req) => { isConnecting = true; sshConn = new Client(); + sendLog("dns", "info", `Starting address resolution of ${ip}`); + sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); + const connectionTimeout = setTimeout(() => { if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { @@ -1142,6 +1147,7 @@ wss.on("connection", async (ws: WebSocket, req) => { socks5Username?: string; socks5Password?: string; socks5ProxyChain?: unknown; + portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"]; terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"]; enableSessionLogging?: boolean; }) @@ -1149,17 +1155,13 @@ wss.on("connection", async (ws: WebSocket, req) => { if (id && userId) { try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); resolvedHostData = (await resolveHostById( id, userId, )) as unknown as typeof resolvedHostData; if (resolvedHostData) { - ip = resolvedHostData.ip || ip; - port = resolvedHostData.port || port; - username = resolvedHostData.username || username; - if ( (!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) && resolvedHostData.jumpHosts && @@ -1185,6 +1187,20 @@ wss.on("connection", async (ws: WebSocket, req) => { if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) { hostConfig.terminalConfig = resolvedHostData.terminalConfig; } + + if ( + (!hostConfig.portKnockSequence || + hostConfig.portKnockSequence.length === 0) && + resolvedHostData.portKnockSequence && + resolvedHostData.portKnockSequence.length > 0 + ) { + hostConfig.portKnockSequence = resolvedHostData.portKnockSequence; + sendLog( + "port_knock", + "info", + `Loaded ${resolvedHostData.portKnockSequence.length} port knock(s) from server-side host data`, + ); + } } } catch (error) { sshLogger.warn(`Failed to resolve server-side host data for ${id}`, { @@ -1209,8 +1225,11 @@ wss.on("connection", async (ws: WebSocket, req) => { if (id && userId && !password && !key) { try { if (resolvedHostData) { + ip = resolvedHostData.ip || ip; + port = resolvedHostData.port || port; + username = resolvedHostData.username || username; resolvedCredentials = { - username, + username: resolvedHostData.username || username, password: resolvedHostData.password, key: resolvedHostData.key, keyPassword: keyPassword || resolvedHostData.keyPassword, @@ -1234,8 +1253,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } else if (credentialId && id && userId) { try { if (resolvedHostData) { + ip = resolvedHostData.ip || ip; + port = resolvedHostData.port || port; + username = resolvedHostData.username || username; resolvedCredentials = { - username, + username: resolvedHostData.username || username, password: resolvedHostData.password, key: resolvedHostData.key, // Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value @@ -1267,6 +1289,39 @@ wss.on("connection", async (ws: WebSocket, req) => { } sendLog("dns", "info", `Starting address resolution of ${ip}`); + let connectHost = ip; + try { + const resolution = await resolveHostForSshConnect(ip); + connectHost = resolution.host; + if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) { + sendLog( + "dns", + "success", + `Resolved ${ip} to ${resolution.resolvedAddress}`, + { attempts: resolution.attempts }, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + sshLogger.error("SSH hostname resolution failed", error, { + operation: "terminal_dns_resolve", + hostId: id, + ip, + port, + transient: isRetriableDnsError(error), + }); + sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`); + ws.send( + JSON.stringify({ + type: "error", + message: isRetriableDnsError(error) + ? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again." + : "SSH error: Could not resolve hostname from the Termix server container.", + }), + ); + cleanupAuthState(connectionTimeout); + return; + } sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); sshConn.on("ready", () => { @@ -1767,23 +1822,15 @@ wss.on("connection", async (ws: WebSocket, req) => { if (id && hostConfig.userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, id), - eq(hosts.userId, hostConfig.userId!), - ), - ), - "ssh_data", - hostConfig.userId!, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + id, + hostConfig.userId!, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === hostConfig.userId && host.name + ? host.name : `${username}@${ip}:${port}`; await axios.post( @@ -1832,72 +1879,6 @@ wss.on("connection", async (ws: WebSocket, req) => { keyboardInteractiveResponded, }); - const isAuthFailure = - err.message.includes("All configured authentication methods failed") || - err.message.includes("authentication failed") || - err.message.includes("Authentication failed") || - err.message.includes("Permission denied"); - - if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.password && - isAuthFailure && - !hostConfig.passwordFallbackAttempted - ) { - sendLog( - "auth", - "warning", - "SSH key authentication failed; retrying with stored password", - ); - sshLogger.warn("Retrying SSH connection with stored password", { - operation: "terminal_key_password_fallback", - hostId: id, - userId, - }); - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - sshConn = null; - sshStream = null; - handleConnectToHost({ - ...data, - hostConfig: { - ...hostConfig, - authType: "password", - password: resolvedCredentials.password, - key: undefined, - keyPassword: undefined, - keyType: undefined, - passwordFallbackOnly: true, - passwordFallbackAttempted: true, - }, - }).catch((fallbackError) => { - const fallbackMessage = - fallbackError instanceof Error - ? fallbackError.message - : "Unknown error"; - sshLogger.error( - "Password fallback connection failed", - fallbackError, - { - operation: "terminal_key_password_fallback_error", - hostId: id, - userId, - }, - ); - ws.send( - JSON.stringify({ - type: "error", - message: - "Failed to retry with stored password: " + fallbackMessage, - }), - ); - }); - return; - } - if ( resolvedCredentials.authType === "opkssh" && err.message.includes("All configured authentication methods failed") @@ -1911,7 +1892,7 @@ wss.on("connection", async (ws: WebSocket, req) => { (async () => { try { - const { invalidateOPKSSHToken } = await import("./opkssh-auth.js"); + const { invalidateOPKSSHToken } = await import("../opkssh-auth.js"); await invalidateOPKSSHToken(userId, id, "SSH auth failed"); } catch (invalidateError) { sshLogger.error("Failed to invalidate OPKSSH token", { @@ -1964,7 +1945,7 @@ wss.on("connection", async (ws: WebSocket, req) => { )?.id; if (profileId) { const { deleteVaultCert } = - await import("./vault-signer-auth.js"); + await import("../vault-signer-auth.js"); await deleteVaultCert(userId, profileId); } } catch (invalidateError) { @@ -2305,7 +2286,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id); const connectConfig: Record = { - host: ip, + host: connectHost, port, username, tryKeyboard: resolvedCredentials.authType !== "tailscale", @@ -2396,7 +2377,7 @@ wss.on("connection", async (ws: WebSocket, req) => { resolvedCredentials.certPublicKey.trim() ) { try { - const { setupCACertAuth } = await import("./opkssh-cert-auth.js"); + const { setupCACertAuth } = await import("../opkssh-cert-auth.js"); await setupCACertAuth( connectConfig, sshConn, @@ -2459,7 +2440,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } else if (resolvedCredentials.authType === "opkssh") { sendLog("auth", "info", "Using OPKSSH certificate authentication"); try { - const { getOPKSSHToken } = await import("./opkssh-auth.js"); + const { getOPKSSHToken } = await import("../opkssh-auth.js"); const token = await getOPKSSHToken(userId, id); if (!token) { @@ -2479,7 +2460,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog("auth", "info", "Using cached OPKSSH certificate"); - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth(connectConfig, sshConn, token, username); } catch (opksshError) { sshLogger.error("OPKSSH authentication error", opksshError, { @@ -2509,7 +2490,7 @@ wss.on("connection", async (ws: WebSocket, req) => { throw new Error("Host has no Vault signer profile configured"); } - const { getVaultCert } = await import("./vault-signer-auth.js"); + const { getVaultCert } = await import("../vault-signer-auth.js"); const cert = await getVaultCert(userId, vaultProfile.id); if (!cert) { @@ -2529,7 +2510,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog("auth", "info", "Using cached Vault-signed certificate"); - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); await setupOPKSSHCertAuth( connectConfig, sshConn, @@ -2556,14 +2537,15 @@ wss.on("connection", async (ws: WebSocket, req) => { } } else if (resolvedCredentials.authType === "agent") { sendLog("auth", "info", "Using SSH agent authentication"); - const result = await applyAgentAuth( - connectConfig as Record, + const result = await resolveAgentSocket( hostConfig.terminalConfig as Record | undefined, ); if ("error" in result) { ws.send(JSON.stringify({ type: "error", message: result.error })); return; } + const { createAgent } = ssh2Pkg; + connectConfig.agent = createAgent(result.socketPath); sendLog( "auth", "info", @@ -2864,6 +2846,7 @@ wss.on("connection", async (ws: WebSocket, req) => { if (session) { session.cols = data.cols; session.rows = data.rows; + sessionManager.bufferResize(session.id, data.cols, data.rows); } ws.send( JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }), diff --git a/src/backend/ssh/terminal-session-manager.ts b/src/backend/hosts/terminal/session-manager.ts similarity index 76% rename from src/backend/ssh/terminal-session-manager.ts rename to src/backend/hosts/terminal/session-manager.ts index 01d19186..1e1d3845 100644 --- a/src/backend/ssh/terminal-session-manager.ts +++ b/src/backend/hosts/terminal/session-manager.ts @@ -2,9 +2,11 @@ import { type Client, type ClientChannel } from "ssh2"; import { WebSocket } from "ws"; import fs from "fs"; import path from "path"; -import { sshLogger } from "../utils/logger.js"; -import { getDb } from "../database/db/index.js"; -import { sessionRecordings } from "../database/db/schema.js"; +import { sshLogger } from "../../utils/logger.js"; +import { + getCurrentSettingValue, + createCurrentSessionRecordingRepository, +} from "../../database/repositories/factory.js"; const MAX_BUFFER_BYTES = 512 * 1024; const DATA_DIR = process.env.DATA_DIR ?? "./db/data"; @@ -36,6 +38,12 @@ export interface TerminalSession { outputBuffer: string[]; outputBufferBytes: number; + recordingPath: string | null; + recordingHeader: string | null; + recordingBytes: number; + recordingId: number | null; + recordingWriteChain: Promise; + recordingPersistChain: Promise; tmuxSessionName: string | null; sessionLoggingEnabled: boolean; sessionStartedAt: number; @@ -117,6 +125,19 @@ class TerminalSessionManager { const id = crypto.randomUUID(); const now = Date.now(); + let recordingPath: string | null = null; + let recordingHeader: string | null = null; + if (sessionLoggingEnabled) { + const userLogDir = path.join(SESSION_LOGS_DIR, userId); + recordingPath = path.join(userLogDir, `${id}.cast`); + recordingHeader = `${JSON.stringify({ + version: 2, + width: cols, + height: rows, + timestamp: Math.floor(now / 1000), + env: { TERM: "xterm-256color", SHELL: "/bin/sh" }, + })}\n`; + } const session: TerminalSession = { id, userId, @@ -135,6 +156,12 @@ class TerminalSessionManager { detachTimeout: null, outputBuffer: [], outputBufferBytes: 0, + recordingPath, + recordingHeader, + recordingBytes: 0, + recordingId: null, + recordingWriteChain: Promise.resolve(), + recordingPersistChain: Promise.resolve(), tmuxSessionName: null, sessionLoggingEnabled, sessionStartedAt: now, @@ -334,6 +361,9 @@ class TerminalSessionManager { } this.maybePersistLog(session, true); + if (session.recordingPath && session.recordingBytes === 0) { + fs.promises.unlink(session.recordingPath).catch(() => {}); + } if (session.sshStream) { try { @@ -376,65 +406,48 @@ class TerminalSessionManager { }); } - private stripAnsi(raw: string): string { - return ( - raw - // ESC sequences: CSI, OSC, DCS, PM, APC, SOS - .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "") - // Single-char ESC sequences (e.g. ESC M, ESC =) - .replace(/\x1b[^[\]PX^_]/g, "") - // Other C0/C1 control chars except newline, carriage return, tab - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - // Collapse carriage returns used for line overwrites - .replace(/[^\n]*\r(?!\n)/g, "") - ); - } - private maybePersistLog(session: TerminalSession, force = false): void { if (!session.sessionLoggingEnabled) return; - if (session.outputBufferBytes === 0) return; - // Only save if new output arrived since last persist (unless forced) - if (!force && session.outputBufferBytes === session.lastPersistedBytes) - return; - const snapshot = session.outputBuffer.join(""); - session.lastPersistedBytes = session.outputBufferBytes; - this.persistSessionLog(session, snapshot).catch((err) => { - sshLogger.warn("Failed to persist session log", { - operation: "session_log_persist_error", - sessionId: session.id, - error: err instanceof Error ? err.message : String(err), + if (session.recordingBytes === 0) return; + if (!force && session.recordingBytes === session.lastPersistedBytes) return; + session.lastPersistedBytes = session.recordingBytes; + session.recordingPersistChain = session.recordingPersistChain + .then(() => this.persistSessionLog(session)) + .catch((err) => { + sshLogger.warn("Failed to persist session log", { + operation: "session_log_persist_error", + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }); }); - }); } - private async persistSessionLog( - session: TerminalSession, - logContent: string, - ): Promise { - const cleaned = this.stripAnsi(logContent); - if (!cleaned.trim()) return; - - const userLogDir = path.join(SESSION_LOGS_DIR, session.userId); - await fs.promises.mkdir(userLogDir, { recursive: true }); - - const logFile = path.join(userLogDir, `${session.id}.log`); - await fs.promises.writeFile(logFile, cleaned, "utf-8"); - + private async persistSessionLog(session: TerminalSession): Promise { + if (!session.recordingPath) return; + await session.recordingWriteChain; const endedAt = Date.now(); const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000); try { - const db = getDb(); - await db.insert(sessionRecordings).values({ - hostId: session.hostId, - userId: session.userId, - startedAt: new Date(session.sessionStartedAt).toISOString(), - endedAt: new Date(endedAt).toISOString(), - duration, - recordingPath: logFile, - }); + const repo = createCurrentSessionRecordingRepository(); + if (session.recordingId == null) { + const created = await repo.create({ + hostId: session.hostId, + userId: session.userId, + startedAt: new Date(session.sessionStartedAt).toISOString(), + endedAt: new Date(endedAt).toISOString(), + duration, + recordingPath: session.recordingPath, + protocol: "ssh", + format: "asciicast", + }); + session.recordingId = created.id; + } else { + await repo.updateEnded(session.recordingId, { + endedAt: new Date(endedAt).toISOString(), + duration, + }); + } } catch (err) { sshLogger.warn("Failed to insert session recording row", { operation: "session_recording_insert_error", @@ -449,7 +462,7 @@ class TerminalSessionManager { userId: session.userId, hostId: session.hostId, duration, - bytes: cleaned.length, + bytes: session.recordingBytes, }); } @@ -477,6 +490,47 @@ class TerminalSessionManager { const removed = session.outputBuffer.shift(); if (removed) session.outputBufferBytes -= removed.length; } + + this.recordSessionEvent(session, "o", data); + } + + bufferInput(sessionId: string, data: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + this.recordSessionEvent(session, "i", data); + } + + bufferResize(sessionId: string, cols: number, rows: number): void { + const session = this.sessions.get(sessionId); + if (!session) return; + this.recordSessionEvent(session, "r", `${cols}x${rows}`); + } + + private recordSessionEvent( + session: TerminalSession, + type: "i" | "o" | "r", + data: string, + ): void { + if (!session.sessionLoggingEnabled || !session.recordingPath || !data) + return; + const elapsed = (Date.now() - session.sessionStartedAt) / 1000; + const line = `${JSON.stringify([elapsed, type, data])}\n`; + const firstEvent = session.recordingBytes === 0; + session.recordingBytes += Buffer.byteLength(line); + session.recordingWriteChain = session.recordingWriteChain.then(async () => { + if (firstEvent) { + await fs.promises.mkdir(path.dirname(session.recordingPath!), { + recursive: true, + }); + await fs.promises.writeFile( + session.recordingPath!, + `${session.recordingHeader}${line}`, + "utf8", + ); + return; + } + await fs.promises.appendFile(session.recordingPath!, line, "utf8"); + }); } flushBuffer(session: TerminalSession): string | null { @@ -494,14 +548,9 @@ class TerminalSessionManager { private getTimeoutMs(): number { try { - const db = getDb(); - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", - ) - .get() as { value: string } | undefined; - if (row) { - const minutes = parseInt(row.value, 10); + const value = getCurrentSettingValue("terminal_session_timeout_minutes"); + if (value) { + const minutes = parseInt(value, 10); if (!isNaN(minutes) && minutes > 0) { return minutes * 60_000; } diff --git a/src/backend/ssh/tmux-helper.ts b/src/backend/hosts/tmux/helper.ts similarity index 96% rename from src/backend/ssh/tmux-helper.ts rename to src/backend/hosts/tmux/helper.ts index 54a16afb..22b886c8 100644 --- a/src/backend/ssh/tmux-helper.ts +++ b/src/backend/hosts/tmux/helper.ts @@ -1,5 +1,5 @@ import type { Client, ClientChannel } from "ssh2"; -import { sshLogger } from "../utils/logger.js"; +import { sshLogger } from "../../utils/logger.js"; export interface TmuxSessionInfo { name: string; @@ -22,7 +22,8 @@ const TMUX_PATH_DIRS = [ ]; export function withTmuxPath(command: string): string { - return `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; ${command}`; + const script = `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; export PATH; ${command}`; + return `/bin/sh -c ${shellEscape(script)}`; } export function tmuxCommand(args: string): string { @@ -69,7 +70,7 @@ export function execCommand(conn: Client, command: string): Promise { */ export async function detectTmux(conn: Client): Promise { try { - await execCommand(conn, withTmuxPath("command -v tmux")); + await execCommand(conn, tmuxCommand("-V")); } catch { return { available: false, sessions: [] }; } diff --git a/src/backend/ssh/tmux-monitor.ts b/src/backend/hosts/tmux/index.ts similarity index 86% rename from src/backend/ssh/tmux-monitor.ts rename to src/backend/hosts/tmux/index.ts index 701b1b94..1755c5cb 100644 --- a/src/backend/ssh/tmux-monitor.ts +++ b/src/backend/hosts/tmux/index.ts @@ -1,26 +1,29 @@ import express from "express"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; -import { eq, and } from "drizzle-orm"; -import { createCorsMiddleware } from "../utils/cors-config.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; -import { tmuxSessionTags, users } from "../database/db/schema.js"; -import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; -import { sshLogger } from "../utils/logger.js"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { resolveHostById, checkHostAccess } from "./host-resolver.js"; -import { createJumpHostChain } from "./jump-host-chain.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; +import { createCorsMiddleware } from "../../utils/cors-config.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { + createCurrentTmuxSessionTagRepository, + createCurrentUserRepository, +} from "../../database/repositories/factory.js"; +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { sshLogger } from "../../utils/logger.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { SSHHostKeyVerifier } from "../host-key-verifier.js"; +import { resolveHostById, checkHostAccess } from "../host-resolver.js"; +import type { HostAction } from "../../utils/permission-manager.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; +import { applyAgentAuth } from "../terminal-auth-helpers.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import { withConnection } from "./ssh-connection-pool.js"; -import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js"; +} from "../../utils/socks5-helper.js"; +import { withConnection } from "../ssh-connection-pool.js"; +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { execCommand, tmuxCommand } from "./helper.js"; import { SEP, parseSessions, @@ -35,8 +38,8 @@ import { type TmuxSessionSummary, type TmuxWindow, type PaneMetrics, -} from "./tmux-monitor-helpers.js"; -import type { SSHHost, AuthenticatedRequest } from "../../types/index.js"; +} from "./monitor-helpers.js"; +import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js"; const PANE_ID_RE = /^%\d+$/; // tmux session names cannot contain ":" or "."; keep to a conservative @@ -52,10 +55,8 @@ interface TmuxSessionOverview extends TmuxSessionSummary { tags: string[]; } -// --------------------------------------------------------------------------- // SSH connection (lean variant of the per-module pattern used by server-stats // and docker; jump hosts and SOCKS5 reuse the shared helpers) -// --------------------------------------------------------------------------- async function buildSshConfig(host: SSHHost): Promise { const base: ConnectConfig = { @@ -95,6 +96,8 @@ async function buildSshConfig(host: SSHHost): Promise { } } else if (host.authType === "none") { // no credentials needed + } else if (host.authType === "vault") { + // cert auth setup happens in connectToHost (needs client instance) } else if (host.authType === "agent") { const result = await applyAgentAuth( base as Record, @@ -118,6 +121,12 @@ export function connectToHost(host: SSHHost): () => Promise { const config = await buildSshConfig(host); const client = new Client(); + if (host.authType === "vault") { + const { setupVaultSshSignerAuth } = + await import("../vault-ssh-connect.js"); + await setupVaultSshSignerAuth(config, client, host); + } + const proxyConfig: SOCKS5Config | null = host.useSocks5 && (host.socks5Host || @@ -201,8 +210,17 @@ export function connectToHost(host: SSHHost): () => Promise { client.connect(config); }, ); - } else { + } else if (config.sock) { client.connect(config); + } else { + resolveSshConnectConfigHost(config) + .then(() => { + client.connect(config); + }) + .catch((error) => { + clearTimeout(timeout); + reject(error); + }); } }); }; @@ -222,13 +240,11 @@ async function withHostConnection( return withConnection(getPoolKey(host), connectToHost(host), fn); } -// --------------------------------------------------------------------------- // tmux queries -// --------------------------------------------------------------------------- async function tmuxAvailable(conn: Client): Promise { try { - await execCommand(conn, withTmuxPath("command -v tmux")); + await execCommand(conn, tmuxCommand("-V")); return true; } catch { return false; @@ -269,21 +285,10 @@ async function fetchSessionTags( userId: string, hostId: number, ): Promise> { - const rows = await getDb() - .select() - .from(tmuxSessionTags) - .where( - and( - eq(tmuxSessionTags.userId, userId), - eq(tmuxSessionTags.hostId, hostId), - ), - ); - const bySession = new Map(); - for (const row of rows) { - if (!bySession.has(row.sessionName)) bySession.set(row.sessionName, []); - bySession.get(row.sessionName)!.push(row.tag); - } - return bySession; + return createCurrentTmuxSessionTagRepository().listByUserAndHost( + userId, + hostId, + ); } async function collectPaneMetrics( @@ -318,9 +323,7 @@ async function collectPaneMetrics( ); } -// --------------------------------------------------------------------------- // Express app -// --------------------------------------------------------------------------- const app = express(); const authManager = AuthManager.getInstance(); @@ -341,7 +344,7 @@ app.use(authManager.createAuthMiddleware()); async function requireHost( req: express.Request, res: express.Response, - permission: "read" | "execute" = "read", + permission: HostAction = "connect", ): Promise { const userId = (req as unknown as AuthenticatedRequest).userId; const hostId = parseInt(String(req.params.hostId), 10); @@ -349,7 +352,7 @@ async function requireHost( res.status(400).json({ error: "Invalid host ID" }); return null; } - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { res.status(401).json({ error: "User data is locked" }); return null; } @@ -404,12 +407,8 @@ async function auditTmuxAction( const { ipAddress, userAgent } = getRequestMeta(req); let username = userId; try { - const actor = await getDb() - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - username = actor[0]?.username ?? userId; + const actor = await createCurrentUserRepository().findById(userId); + username = actor?.username ?? userId; } catch { // fall back to the raw user id } @@ -505,7 +504,7 @@ app.get("/tmux_monitor/:hostId/overview", async (req, res) => { // Focus a pane: select its window and pane on the server so every attached // client (including the monitor's embedded terminal) switches to it. app.post("/tmux_monitor/:hostId/focus", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const paneId = String((req.body as { paneId?: string })?.paneId || ""); @@ -532,7 +531,7 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => { // Create a detached session. Starts the tmux server if none is running. app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const name = String((req.body as { name?: string })?.name || "").trim(); @@ -565,7 +564,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { // target's meaning (":" and "." are window/pane separators in tmux targets); // "=" prefixes the target for an exact-name match. app.post("/tmux_monitor/:hostId/windows", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const sessionName = String( @@ -599,7 +598,7 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => { // Rename a session. Saved tags follow the session to its new name (for every // user — the session itself is shared on the host). app.post("/tmux_monitor/:hostId/rename", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const body = req.body as { sessionName?: string; newName?: string }; @@ -621,16 +620,11 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => { ), ), ); - await getDb() - .update(tmuxSessionTags) - .set({ sessionName: newName }) - .where( - and( - eq(tmuxSessionTags.hostId, host.id), - eq(tmuxSessionTags.sessionName, sessionName), - ), - ); - await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); + await createCurrentTmuxSessionTagRepository().renameSessionForHost( + host.id, + sessionName, + newName, + ); sshLogger.info("tmux session renamed", { operation: "tmux_session_rename", hostId: host.id, @@ -656,7 +650,7 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => { // Kill a session and drop its saved tags. app.post("/tmux_monitor/:hostId/kill", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const sessionName = String( @@ -673,15 +667,10 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => { tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`), ), ); - await getDb() - .delete(tmuxSessionTags) - .where( - and( - eq(tmuxSessionTags.hostId, host.id), - eq(tmuxSessionTags.sessionName, sessionName), - ), - ); - await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); + await createCurrentTmuxSessionTagRepository().deleteSessionForHost( + host.id, + sessionName, + ); sshLogger.info("tmux session killed", { operation: "tmux_session_kill", hostId: host.id, @@ -700,7 +689,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => { // Kill a window (and every pane in it). Killing the last window of a session // ends the session — tmux semantics. app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const body = req.body as { sessionName?: string; windowIndex?: number }; @@ -747,7 +736,7 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { // Kill a single pane. Killing the last pane of a window closes the window, // and the last window of a session ends the session — tmux semantics. app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const paneId = String((req.body as { paneId?: string })?.paneId || ""); @@ -778,7 +767,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { // "v" below — matching tmux's own -h/-v semantics. The new pane starts in the // source pane's working directory. app.post("/tmux_monitor/:hostId/split", async (req, res) => { - const host = await requireHost(req, res, "execute"); + const host = await requireHost(req, res, "connect"); if (!host) return; const body = req.body as { paneId?: string; direction?: string }; @@ -918,27 +907,12 @@ app.put("/tmux_monitor/:hostId/tags", async (req, res) => { ].slice(0, 20); try { - const db = getDb(); - await db - .delete(tmuxSessionTags) - .where( - and( - eq(tmuxSessionTags.userId, userId), - eq(tmuxSessionTags.hostId, host.id), - eq(tmuxSessionTags.sessionName, sessionName), - ), - ); - if (cleanTags.length > 0) { - await db.insert(tmuxSessionTags).values( - cleanTags.map((tag) => ({ - userId, - hostId: host.id, - sessionName, - tag, - })), - ); - } - await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); + await createCurrentTmuxSessionTagRepository().replaceForUserHostSession( + userId, + host.id, + sessionName, + cleanTags, + ); res.json({ sessionName, tags: cleanTags }); } catch (err) { sshLogger.error( diff --git a/src/backend/ssh/tmux-monitor-helpers.ts b/src/backend/hosts/tmux/monitor-helpers.ts similarity index 100% rename from src/backend/ssh/tmux-monitor-helpers.ts rename to src/backend/hosts/tmux/monitor-helpers.ts diff --git a/src/backend/ssh/transfer-paths.ts b/src/backend/hosts/transfer-paths.ts similarity index 100% rename from src/backend/ssh/transfer-paths.ts rename to src/backend/hosts/transfer-paths.ts diff --git a/src/backend/ssh/tunnel-c2s-relay-utils.ts b/src/backend/hosts/tunnel/c2s-relay-utils.ts similarity index 100% rename from src/backend/ssh/tunnel-c2s-relay-utils.ts rename to src/backend/hosts/tunnel/c2s-relay-utils.ts diff --git a/src/backend/ssh/tunnel-c2s-relay.ts b/src/backend/hosts/tunnel/c2s-relay.ts similarity index 95% rename from src/backend/ssh/tunnel-c2s-relay.ts rename to src/backend/hosts/tunnel/c2s-relay.ts index af71f67c..e9cd6584 100644 --- a/src/backend/ssh/tunnel-c2s-relay.ts +++ b/src/backend/hosts/tunnel/c2s-relay.ts @@ -1,9 +1,9 @@ import { Client, type ClientChannel } from "ssh2"; import type { WebSocket } from "ws"; -import type { TunnelConfig } from "../../types/index.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; -import { tunnelLogger } from "../utils/logger.js"; -import { PermissionManager } from "../utils/permission-manager.js"; +import type { TunnelConfig } from "../../../types/index.js"; +import { createSocks5Connection } from "../../utils/socks5-helper.js"; +import { tunnelLogger } from "../../utils/logger.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; import { applyAuthOptions, bindForwardIn, @@ -11,12 +11,9 @@ import { forwardOut, getManagedTunnelAlgorithms, unbindForwardIn, -} from "./tunnel-ssh-primitives.js"; -import { - sendC2SMessage, - writeC2SRemoteChunk, -} from "./tunnel-c2s-relay-utils.js"; -import { getTunnelMode } from "./tunnel-utils.js"; +} from "./ssh-primitives.js"; +import { sendC2SMessage, writeC2SRemoteChunk } from "./c2s-relay-utils.js"; +import { getTunnelMode } from "./utils.js"; export type C2SOpenMessage = { type: "open" | "test"; @@ -39,13 +36,13 @@ async function resolveC2STunnelSource( const accessInfo = await permissionManager.canAccessHost( userId, tunnelConfig.sourceHostId, - "read", + "connect", ); if (!accessInfo.hasAccess) { throw new Error("Access denied to this host"); } - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById(tunnelConfig.sourceHostId, userId); if (!resolvedHost) { throw new Error("Endpoint SSH host not found"); diff --git a/src/backend/hosts/tunnel/index.ts b/src/backend/hosts/tunnel/index.ts new file mode 100644 index 00000000..bbbfc4fb --- /dev/null +++ b/src/backend/hosts/tunnel/index.ts @@ -0,0 +1,92 @@ +import express from "express"; +import { createServer } from "http"; + +import { createCorsMiddleware } from "../../utils/cors-config.js"; +import cookieParser from "cookie-parser"; +import { WebSocketServer } from "ws"; + +import { tunnelLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; + +import { + describeC2SRelayError, + extractRequestToken, + sendC2SError, +} from "./c2s-relay-utils.js"; +import { + handleC2SRelayOpen, + handleC2SRelayTest, + type C2SOpenMessage, +} from "./c2s-relay.js"; + +import { registerTunnelRoutes } from "./routes.js"; +import { initializeAutoStartTunnels } from "./manager.js"; + +const authManager = AuthManager.getInstance(); + +const app = express(); +app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); +app.use(cookieParser()); +app.use(express.json()); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); + +registerTunnelRoutes(app); + +const PORT = 30003; +const server = createServer(app); +const c2sRelayWss = new WebSocketServer({ + server, + path: "/ssh/tunnel/c2s/stream", +}); + +c2sRelayWss.on("connection", (ws, req) => { + let opened = false; + + ws.once("message", async (raw) => { + try { + const token = extractRequestToken(req); + const payload = token ? await authManager.verifyJWTToken(token) : null; + if (!payload?.userId || payload.pendingTOTP) { + sendC2SError(ws, "Authentication required"); + ws.close(); + return; + } + + const message = JSON.parse(raw.toString()) as C2SOpenMessage; + if (message.type !== "open" && message.type !== "test") { + throw new Error("Invalid client tunnel relay request"); + } + + opened = true; + if (message.type === "test") { + await handleC2SRelayTest(ws, message, payload.userId); + } else { + await handleC2SRelayOpen(ws, message, payload.userId); + } + } catch (error) { + const message = describeC2SRelayError(error); + tunnelLogger.error("Failed to open C2S relay", error, { + operation: "c2s_relay_open_failed", + }); + sendC2SError(ws, message); + ws.close(); + } + }); + + ws.on("close", () => { + if (!opened) { + tunnelLogger.info("C2S relay closed before opening", { + operation: "c2s_relay_closed_before_open", + }); + } + }); +}); + +server.listen(PORT, () => { + setTimeout(() => { + initializeAutoStartTunnels(); + }, 2000); +}); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/hosts/tunnel/manager.ts similarity index 70% rename from src/backend/ssh/tunnel.ts rename to src/backend/hosts/tunnel/manager.ts index 4580ef6c..527c5934 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/hosts/tunnel/manager.ts @@ -1,38 +1,28 @@ -import express, { type Response } from "express"; -import { createServer } from "http"; +import { type Response } from "express"; import { createServer as createTcpServer, Socket as TcpSocket, type Server as TcpServer, } from "net"; -import { createCorsMiddleware } from "../utils/cors-config.js"; -import cookieParser from "cookie-parser"; import { Client, type ClientChannel } from "ssh2"; -import { WebSocketServer } from "ws"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { ChildProcess } from "child_process"; import axios from "axios"; -import { getDb } from "../database/db/index.js"; -import { sshCredentials } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; import type { SSHHost, TunnelConfig, TunnelStatus, VerificationData, - AuthenticatedRequest, -} from "../../types/index.js"; -import { CONNECTION_STATES } from "../../types/index.js"; -import { tunnelLogger } from "../utils/logger.js"; -import { logAudit } from "../utils/audit-logger.js"; -import { SystemCrypto } from "../utils/system-crypto.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { DataCrypto } from "../utils/data-crypto.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { PermissionManager } from "../utils/permission-manager.js"; -import { withConnection } from "./ssh-connection-pool.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; +} from "../../../types/index.js"; +import { CONNECTION_STATES } from "../../../types/index.js"; +import { tunnelLogger } from "../../utils/logger.js"; +import { logAudit } from "../../utils/audit-logger.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { createSocks5Connection } from "../../utils/socks5-helper.js"; +import { withConnection } from "../ssh-connection-pool.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { applyAuthOptions, bindForwardIn, @@ -41,7 +31,7 @@ import { getManagedTunnelAlgorithms, pipeTunnelStreams, unbindForwardIn, -} from "./tunnel-ssh-primitives.js"; +} from "./ssh-primitives.js"; import { classifyTunnelError, getTunnelBindHost, @@ -49,60 +39,41 @@ import { getTunnelMode, getTunnelScope, normalizeTunnelName, - validateTunnelConfig, -} from "./tunnel-utils.js"; -import { - describeC2SRelayError, - extractRequestToken, - sendC2SError, -} from "./tunnel-c2s-relay-utils.js"; -import { - handleC2SRelayOpen, - handleC2SRelayTest, - type C2SOpenMessage, -} from "./tunnel-c2s-relay.js"; -import { handleSocks5Connect } from "./tunnel-socks5-relay.js"; +} from "./utils.js"; -const app = express(); -app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); -app.use(cookieParser()); -app.use(express.json()); -app.use((_req, res, next) => { - res.setHeader("Cache-Control", "no-store"); - next(); -}); +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { handleSocks5Connect } from "./socks5-relay.js"; -const authManager = AuthManager.getInstance(); -const permissionManager = PermissionManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); +export const activeTunnels = new Map(); +export const retryCounters = new Map(); +export const connectionStatus = new Map(); +export const tunnelVerifications = new Map(); +export const manualDisconnects = new Set(); +export const verificationTimers = new Map(); +export const activeRetryTimers = new Map(); +export const countdownIntervals = new Map(); +export const retryExhaustedTunnels = new Set(); +export const cleanupInProgress = new Set(); +export const tunnelConnecting = new Set(); +export const lastTunnelErrors = new Map(); +export const lastTunnelErrorTypes = new Map< + string, + TunnelStatus["errorType"] +>(); -const activeTunnels = new Map(); -const retryCounters = new Map(); -const connectionStatus = new Map(); -const tunnelVerifications = new Map(); -const manualDisconnects = new Set(); -const verificationTimers = new Map(); -const activeRetryTimers = new Map(); -const countdownIntervals = new Map(); -const retryExhaustedTunnels = new Set(); -const cleanupInProgress = new Set(); -const tunnelConnecting = new Set(); -const lastTunnelErrors = new Map(); -const lastTunnelErrorTypes = new Map(); +export const tunnelConfigs = new Map(); +export const activeTunnelProcesses = new Map(); +export const pendingTunnelOperations = new Map>(); +export const tunnelStatusClients = new Set(); -const tunnelConfigs = new Map(); -const activeTunnelProcesses = new Map(); -const pendingTunnelOperations = new Map>(); -const tunnelStatusClients = new Set(); +export const INTERNAL_HOST_API_BASE_URL = "http://localhost:30001/host/db/host"; +export const AUTOSTART_FETCH_RETRIES = 6; -const INTERNAL_HOST_API_BASE_URL = "http://localhost:30001/host/db/host"; -const AUTOSTART_FETCH_RETRIES = 6; - -function sleep(ms: number): Promise { +export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function describeAxiosError(error: unknown): string { +export function describeAxiosError(error: unknown): string { if (axios.isAxiosError(error)) { return error.response ? `${error.response.status} ${error.response.statusText}` @@ -112,7 +83,7 @@ function describeAxiosError(error: unknown): string { return error instanceof Error ? error.message : "Unknown error"; } -async function fetchInternalHosts( +export async function fetchInternalHosts( path: "internal" | "internal/all", internalAuthToken: string, ): Promise { @@ -155,7 +126,7 @@ async function fetchInternalHosts( ); } -type ActiveTunnelRuntime = { +export type ActiveTunnelRuntime = { sourceClient: Client; endpointClient?: Client; bindClient?: Client; @@ -165,9 +136,9 @@ type ActiveTunnelRuntime = { close: () => void; }; -const activeTunnelRuntimes = new Map(); +export const activeTunnelRuntimes = new Map(); -function findHostByTunnelEndpoint( +export function findHostByTunnelEndpoint( hosts: SSHHost[], endpointHost?: string, ): SSHHost | undefined { @@ -185,7 +156,10 @@ function findHostByTunnelEndpoint( }); } -function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { +export function broadcastTunnelStatus( + tunnelName: string, + status: TunnelStatus, +): void { if ( status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName) @@ -231,7 +205,7 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { broadcastTunnelStatusSnapshot(); } -function getAllTunnelStatus(): Record { +export function getAllTunnelStatus(): Record { const tunnelStatus: Record = {}; connectionStatus.forEach((status, key) => { tunnelStatus[key] = status; @@ -239,7 +213,7 @@ function getAllTunnelStatus(): Record { return tunnelStatus; } -function sendTunnelStatusSnapshot(res: Response): void { +export function sendTunnelStatusSnapshot(res: Response): void { try { res.write( `event: statuses\ndata: ${JSON.stringify(getAllTunnelStatus())}\n\n`, @@ -249,13 +223,13 @@ function sendTunnelStatusSnapshot(res: Response): void { } } -function broadcastTunnelStatusSnapshot(): void { +export function broadcastTunnelStatusSnapshot(): void { for (const client of tunnelStatusClients) { sendTunnelStatusSnapshot(client); } } -async function cleanupTunnelResources( +export async function cleanupTunnelResources( tunnelName: string, forceCleanup = false, ): Promise { @@ -368,7 +342,7 @@ async function cleanupTunnelResources( } } -function resetRetryState(tunnelName: string): void { +export function resetRetryState(tunnelName: string): void { retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); lastTunnelErrors.delete(tunnelName); @@ -395,7 +369,7 @@ function resetRetryState(tunnelName: string): void { }); } -async function handleDisconnect( +export async function handleDisconnect( tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, @@ -536,7 +510,7 @@ async function handleDisconnect( } } -function setupPingInterval(tunnelName: string): void { +export function setupPingInterval(tunnelName: string): void { const pingKey = `${tunnelName}_ping`; if (verificationTimers.has(pingKey)) { clearInterval(verificationTimers.get(pingKey)!); @@ -564,7 +538,7 @@ function setupPingInterval(tunnelName: string): void { verificationTimers.set(pingKey, pingInterval); } -async function connectEndpointThroughSource( +export async function connectEndpointThroughSource( sourceClient: Client, tunnelConfig: TunnelConfig, endpointCredentials: { @@ -597,7 +571,7 @@ async function connectEndpointThroughSource( return connectClient(endpointOptions, tunnelConfig.name, "endpoint"); } -function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { +export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { const targetHost = tunnelConfig.targetHost?.trim(); if ( @@ -611,7 +585,7 @@ function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { return targetHost; } -function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { +export function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { if (!tunnelConfig.endpointHost && !tunnelConfig.endpointIP) return true; if ( tunnelConfig.endpointHost === "127.0.0.1" || @@ -629,13 +603,15 @@ function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { return false; } -function shouldEstablishDirectTunnel(tunnelConfig: TunnelConfig): boolean { +export function shouldEstablishDirectTunnel( + tunnelConfig: TunnelConfig, +): boolean { if (isSingleHostTunnel(tunnelConfig)) return true; const mode = getTunnelMode(tunnelConfig); return mode !== "remote" && !tunnelConfig.endpointUsername; } -async function establishDirectTunnel( +export async function establishDirectTunnel( sourceClient: Client, tunnelConfig: TunnelConfig, ): Promise { @@ -762,7 +738,7 @@ async function establishDirectTunnel( activeTunnels.set(tunnelName, sourceClient); } -async function establishManagedS2STunnel( +export async function establishManagedS2STunnel( sourceClient: Client, tunnelConfig: TunnelConfig, endpointCredentials: { @@ -873,7 +849,7 @@ async function establishManagedS2STunnel( activeTunnels.set(tunnelName, sourceClient); } -async function connectSSHTunnel( +export async function connectSSHTunnel( tunnelConfig: TunnelConfig, retryAttempt = 0, ): Promise { @@ -959,7 +935,7 @@ async function connectSSHTunnel( !tunnelConfig.sourceSSHKey ) { try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById( tunnelConfig.sourceHostId, effectiveUserId, @@ -997,7 +973,7 @@ async function connectSSHTunnel( // Legacy: credential resolution from credentialId try { if (tunnelConfig.sourceHostId) { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById( tunnelConfig.sourceHostId, effectiveUserId, @@ -1032,21 +1008,14 @@ async function connectSSHTunnel( if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { try { - const userDataKey = DataCrypto.getUserDataKey( - tunnelConfig.endpointUserId, - ); - if (userDataKey) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)), - "ssh_credentials", - tunnelConfig.endpointUserId, - ); + if (DataCrypto.getUserDataKey(tunnelConfig.endpointUserId) !== null) { + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + tunnelConfig.endpointCredentialId, + tunnelConfig.endpointUserId, + ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { resolvedEndpointCredentials = { password: credential.password as string | undefined, sshKey: (credential.key || credential.privateKey) as @@ -1507,10 +1476,31 @@ async function connectSSHTunnel( } } + try { + await resolveSshConnectConfigHost(connOptions); + } catch (error) { + tunnelLogger.error("Tunnel source hostname resolution failed", error, { + operation: "tunnel_dns_resolve", + tunnelName, + sourceHost: `${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, + retryAttempt, + }); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: + error instanceof Error + ? error.message + : "Failed to resolve tunnel source hostname", + }); + tunnelConnecting.delete(tunnelName); + return; + } + conn.connect(connOptions); } -async function killRemoteTunnelByMarker( +export async function killRemoteTunnelByMarker( tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void, @@ -1539,7 +1529,7 @@ async function killRemoteTunnelByMarker( !tunnelConfig.sourceSSHKey ) { try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById( tunnelConfig.sourceHostId, tunnelConfig.sourceUserId, @@ -1693,6 +1683,10 @@ async function killRemoteTunnelByMarker( } } + if (!connOptions.sock) { + await resolveSshConnectConfigHost(connOptions); + } + return new Promise((resolve, reject) => { const conn = new Client(); conn.on("ready", () => resolve(conn)); @@ -1798,533 +1792,8 @@ async function killRemoteTunnelByMarker( * 200: * description: A list of all tunnel statuses. */ -app.get( - "/ssh/tunnel/status", - authenticateJWT, - (req: AuthenticatedRequest, res: Response) => { - if (!req.userId) { - return res.status(401).json({ error: "Authentication required" }); - } - res.json(getAllTunnelStatus()); - }, -); - -app.get( - "/ssh/tunnel/status/stream", - authenticateJWT, - (req: AuthenticatedRequest, res: Response) => { - if (!req.userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-store, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }); - res.flushHeaders?.(); - - tunnelStatusClients.add(res); - sendTunnelStatusSnapshot(res); - - const heartbeat = setInterval(() => { - try { - res.write(": keepalive\n\n"); - } catch { - closeStream(); - } - }, 30000); - - const closeStream = () => { - clearInterval(heartbeat); - tunnelStatusClients.delete(res); - }; - - req.on("close", closeStream); - }, -); - -/** - * @openapi - * /ssh/tunnel/status/{tunnelName}: - * get: - * summary: Get tunnel status by name - * description: Retrieves the status of a specific SSH tunnel by its name. - * tags: - * - SSH Tunnels - * parameters: - * - in: path - * name: tunnelName - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Tunnel status. - * 404: - * description: Tunnel not found. - */ -app.get( - "/ssh/tunnel/status/:tunnelName", - authenticateJWT, - (req: AuthenticatedRequest, res: Response) => { - if (!req.userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - const tunnelNameParam = req.params.tunnelName; - const tunnelName = Array.isArray(tunnelNameParam) - ? tunnelNameParam[0] - : tunnelNameParam; - const status = connectionStatus.get(tunnelName); - - if (!status) { - return res.status(404).json({ error: "Tunnel not found" }); - } - - res.json({ name: tunnelName, status }); - }, -); - -/** - * @openapi - * /ssh/tunnel/connect: - * post: - * summary: Connect SSH tunnel - * description: Establishes an SSH tunnel connection with the specified configuration. - * tags: - * - SSH Tunnels - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * name: - * type: string - * sourceHostId: - * type: integer - * tunnelIndex: - * type: integer - * responses: - * 200: - * description: Connection request received. - * 400: - * description: Invalid tunnel configuration. - * 401: - * description: Authentication required. - * 403: - * description: Access denied to this host. - * 500: - * description: Failed to connect tunnel. - */ -app.post( - "/ssh/tunnel/connect", - authenticateJWT, - async (req: AuthenticatedRequest, res: Response) => { - const tunnelConfig: TunnelConfig = req.body; - const userId = req.userId; - - if (!userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - if (!tunnelConfig || !tunnelConfig.name) { - return res.status(400).json({ error: "Invalid tunnel configuration" }); - } - - const tunnelName = tunnelConfig.name; - - try { - if (!validateTunnelConfig(tunnelName, tunnelConfig)) { - tunnelLogger.error(`Tunnel config validation failed`, { - operation: "tunnel_connect", - tunnelName, - configHostId: tunnelConfig.sourceHostId, - configTunnelIndex: tunnelConfig.tunnelIndex, - }); - return res.status(400).json({ - error: "Tunnel configuration does not match tunnel name", - }); - } - - if (tunnelConfig.sourceHostId) { - const accessInfo = await permissionManager.canAccessHost( - userId, - tunnelConfig.sourceHostId, - "read", - ); - - if (!accessInfo.hasAccess) { - tunnelLogger.warn("User attempted tunnel connect without access", { - operation: "tunnel_connect_unauthorized", - userId, - hostId: tunnelConfig.sourceHostId, - tunnelName, - }); - return res.status(403).json({ error: "Access denied to this host" }); - } - - if (accessInfo.isShared && !accessInfo.isOwner) { - tunnelConfig.requestingUserId = userId; - } - } - - if (pendingTunnelOperations.has(tunnelName)) { - try { - await pendingTunnelOperations.get(tunnelName); - } catch { - tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName }); - } - } - - const operation = (async () => { - manualDisconnects.delete(tunnelName); - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); - - await cleanupTunnelResources(tunnelName); - - if (tunnelConfigs.has(tunnelName)) { - const existingConfig = tunnelConfigs.get(tunnelName); - if ( - existingConfig && - (existingConfig.sourceHostId !== tunnelConfig.sourceHostId || - existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex) - ) { - throw new Error(`Tunnel name collision detected: ${tunnelName}`); - } - } - - if ( - !isSingleHostTunnel(tunnelConfig) && - (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) - ) { - try { - const systemCrypto = SystemCrypto.getInstance(); - const internalAuthToken = await systemCrypto.getInternalAuthToken(); - - const allHostsResponse = await axios.get( - "http://localhost:30001/host/db/host/internal/all", - { - headers: { - "Content-Type": "application/json", - "X-Internal-Auth-Token": internalAuthToken, - }, - }, - ); - - const allHosts: SSHHost[] = allHostsResponse.data || []; - const endpointHost = findHostByTunnelEndpoint( - allHosts, - tunnelConfig.endpointHost, - ); - - if (!endpointHost) { - if (getTunnelMode(tunnelConfig) !== "remote") { - tunnelConfig.endpointIP = - tunnelConfig.endpointIP || tunnelConfig.endpointHost; - } else { - throw new Error( - `Endpoint host '${tunnelConfig.endpointHost}' not found in database`, - ); - } - } else { - tunnelConfig.endpointIP = endpointHost.ip; - tunnelConfig.endpointSSHPort = endpointHost.port; - tunnelConfig.endpointUsername = endpointHost.username; - tunnelConfig.endpointAuthMethod = endpointHost.authType; - tunnelConfig.endpointKeyType = endpointHost.keyType; - tunnelConfig.endpointCredentialId = endpointHost.credentialId; - tunnelConfig.endpointUserId = endpointHost.userId; - - // Resolve credentials server-side instead of from HTTP response - if (endpointHost.id && endpointHost.userId) { - try { - const { resolveHostById } = - await import("./host-resolver.js"); - const resolved = await resolveHostById( - endpointHost.id, - endpointHost.userId, - ); - if (resolved) { - tunnelConfig.endpointPassword = resolved.password; - tunnelConfig.endpointSSHKey = resolved.key; - tunnelConfig.endpointKeyPassword = resolved.keyPassword; - } - } catch (credError) { - tunnelLogger.warn( - "Failed to resolve endpoint credentials from DB", - { - operation: "tunnel_endpoint_credential_resolve", - endpointHostId: endpointHost.id, - error: - credError instanceof Error - ? credError.message - : "Unknown", - }, - ); - } - } - } - } catch (resolveError) { - tunnelLogger.error( - "Failed to resolve endpoint host", - resolveError, - { - operation: "tunnel_connect_resolve_endpoint_failed", - tunnelName, - endpointHost: tunnelConfig.endpointHost, - }, - ); - throw new Error( - `Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`, - { cause: resolveError }, - ); - } - } - - tunnelConfigs.set(tunnelName, tunnelConfig); - await connectSSHTunnel(tunnelConfig, 0); - })(); - - pendingTunnelOperations.set(tunnelName, operation); - - res.json({ message: "Connection request received", tunnelName }); - - operation - .catch((err) => { - tunnelLogger.error("Tunnel operation failed", err, { - operation: "tunnel_operation_failed", - tunnelName, - }); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: err instanceof Error ? err.message : "Unknown error", - }); - tunnelConnecting.delete(tunnelName); - }) - .finally(() => { - pendingTunnelOperations.delete(tunnelName); - }); - } catch (error) { - tunnelLogger.error("Failed to process tunnel connect", error, { - operation: "tunnel_connect", - tunnelName, - userId, - }); - res.status(500).json({ error: "Failed to connect tunnel" }); - } - }, -); - -/** - * @openapi - * /ssh/tunnel/disconnect: - * post: - * summary: Disconnect SSH tunnel - * description: Disconnects an active SSH tunnel. - * tags: - * - SSH Tunnels - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * tunnelName: - * type: string - * responses: - * 200: - * description: Disconnect request received. - * 400: - * description: Tunnel name required. - * 401: - * description: Authentication required. - * 403: - * description: Access denied. - * 500: - * description: Failed to disconnect tunnel. - */ -app.post( - "/ssh/tunnel/disconnect", - authenticateJWT, - async (req: AuthenticatedRequest, res: Response) => { - const { tunnelName } = req.body; - const userId = req.userId; - - if (!userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - if (!tunnelName) { - return res.status(400).json({ error: "Tunnel name required" }); - } - - try { - const config = tunnelConfigs.get(tunnelName); - if (config && config.sourceHostId) { - const accessInfo = await permissionManager.canAccessHost( - userId, - config.sourceHostId, - "read", - ); - if (!accessInfo.hasAccess) { - return res.status(403).json({ error: "Access denied" }); - } - } - - tunnelLogger.info("Tunnel stop request received", { - operation: "tunnel_stop_request", - userId, - hostId: config?.sourceHostId, - tunnelName, - }); - manualDisconnects.add(tunnelName); - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); - - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } - - await cleanupTunnelResources(tunnelName, true); - tunnelLogger.info("Tunnel cleanup completed", { - operation: "tunnel_cleanup_complete", - userId, - hostId: config?.sourceHostId, - tunnelName, - }); - - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true, - }); - - const tunnelConfig = tunnelConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, tunnelConfig, false); - - setTimeout(() => { - manualDisconnects.delete(tunnelName); - }, 5000); - - res.json({ message: "Disconnect request received", tunnelName }); - } catch (error) { - tunnelLogger.error("Failed to disconnect tunnel", error, { - operation: "tunnel_disconnect", - tunnelName, - userId, - }); - res.status(500).json({ error: "Failed to disconnect tunnel" }); - } - }, -); - -/** - * @openapi - * /ssh/tunnel/cancel: - * post: - * summary: Cancel tunnel retry - * description: Cancels the retry mechanism for a failed SSH tunnel connection. - * tags: - * - SSH Tunnels - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * tunnelName: - * type: string - * responses: - * 200: - * description: Cancel request received. - * 400: - * description: Tunnel name required. - * 401: - * description: Authentication required. - * 403: - * description: Access denied. - * 500: - * description: Failed to cancel tunnel retry. - */ -app.post( - "/ssh/tunnel/cancel", - authenticateJWT, - async (req: AuthenticatedRequest, res: Response) => { - const { tunnelName } = req.body; - const userId = req.userId; - - if (!userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - if (!tunnelName) { - return res.status(400).json({ error: "Tunnel name required" }); - } - - try { - const config = tunnelConfigs.get(tunnelName); - if (config && config.sourceHostId) { - const accessInfo = await permissionManager.canAccessHost( - userId, - config.sourceHostId, - "read", - ); - if (!accessInfo.hasAccess) { - return res.status(403).json({ error: "Access denied" }); - } - } - - retryCounters.delete(tunnelName); - retryExhaustedTunnels.delete(tunnelName); - - if (activeRetryTimers.has(tunnelName)) { - clearTimeout(activeRetryTimers.get(tunnelName)!); - activeRetryTimers.delete(tunnelName); - } - - if (countdownIntervals.has(tunnelName)) { - clearInterval(countdownIntervals.get(tunnelName)!); - countdownIntervals.delete(tunnelName); - } - - await cleanupTunnelResources(tunnelName, true); - - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.DISCONNECTED, - manualDisconnect: true, - }); - - const tunnelConfig = tunnelConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, tunnelConfig, false); - - setTimeout(() => { - manualDisconnects.delete(tunnelName); - }, 5000); - - res.json({ message: "Cancel request received", tunnelName }); - } catch (error) { - tunnelLogger.error("Failed to cancel tunnel retry", error, { - operation: "tunnel_cancel", - tunnelName, - userId, - }); - res.status(500).json({ error: "Failed to cancel tunnel retry" }); - } - }, -); - -async function initializeAutoStartTunnels(): Promise { +export async function initializeAutoStartTunnels(): Promise { try { const systemCrypto = SystemCrypto.getInstance(); const internalAuthToken = await systemCrypto.getInternalAuthToken(); @@ -2435,59 +1904,3 @@ async function initializeAutoStartTunnels(): Promise { ); } } - -const PORT = 30003; -const server = createServer(app); -const c2sRelayWss = new WebSocketServer({ - server, - path: "/ssh/tunnel/c2s/stream", -}); - -c2sRelayWss.on("connection", (ws, req) => { - let opened = false; - - ws.once("message", async (raw) => { - try { - const token = extractRequestToken(req); - const payload = token ? await authManager.verifyJWTToken(token) : null; - if (!payload?.userId || payload.pendingTOTP) { - sendC2SError(ws, "Authentication required"); - ws.close(); - return; - } - - const message = JSON.parse(raw.toString()) as C2SOpenMessage; - if (message.type !== "open" && message.type !== "test") { - throw new Error("Invalid client tunnel relay request"); - } - - opened = true; - if (message.type === "test") { - await handleC2SRelayTest(ws, message, payload.userId); - } else { - await handleC2SRelayOpen(ws, message, payload.userId); - } - } catch (error) { - const message = describeC2SRelayError(error); - tunnelLogger.error("Failed to open C2S relay", error, { - operation: "c2s_relay_open_failed", - }); - sendC2SError(ws, message); - ws.close(); - } - }); - - ws.on("close", () => { - if (!opened) { - tunnelLogger.info("C2S relay closed before opening", { - operation: "c2s_relay_closed_before_open", - }); - } - }); -}); - -server.listen(PORT, () => { - setTimeout(() => { - initializeAutoStartTunnels(); - }, 2000); -}); diff --git a/src/backend/hosts/tunnel/routes.ts b/src/backend/hosts/tunnel/routes.ts new file mode 100644 index 00000000..6eadcf9f --- /dev/null +++ b/src/backend/hosts/tunnel/routes.ts @@ -0,0 +1,596 @@ +import express, { type Response } from "express"; + +import axios from "axios"; +import type { + SSHHost, + TunnelConfig, + AuthenticatedRequest, +} from "../../../types/index.js"; +import { CONNECTION_STATES } from "../../../types/index.js"; +import { tunnelLogger } from "../../utils/logger.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; + +import { getTunnelMode, validateTunnelConfig } from "./utils.js"; + +import { + tunnelConfigs, + activeRetryTimers, + pendingTunnelOperations, + manualDisconnects, + retryExhaustedTunnels, + retryCounters, + countdownIntervals, + tunnelConnecting, + tunnelStatusClients, + connectionStatus, + cleanupTunnelResources, + broadcastTunnelStatus, + handleDisconnect, + sendTunnelStatusSnapshot, + isSingleHostTunnel, + getAllTunnelStatus, + findHostByTunnelEndpoint, + connectSSHTunnel, +} from "./manager.js"; + +const permissionManager = PermissionManager.getInstance(); + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +export function registerTunnelRoutes(app: express.Express): void { + app.get( + "/ssh/tunnel/status", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.json(getAllTunnelStatus()); + }, + ); + + app.get( + "/ssh/tunnel/status/stream", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders?.(); + + tunnelStatusClients.add(res); + sendTunnelStatusSnapshot(res); + + const heartbeat = setInterval(() => { + try { + res.write(": keepalive\n\n"); + } catch { + closeStream(); + } + }, 30000); + + const closeStream = () => { + clearInterval(heartbeat); + tunnelStatusClients.delete(res); + }; + + req.on("close", closeStream); + }, + ); + + /** + * @openapi + * /ssh/tunnel/status/{tunnelName}: + * get: + * summary: Get tunnel status by name + * description: Retrieves the status of a specific SSH tunnel by its name. + * tags: + * - SSH Tunnels + * parameters: + * - in: path + * name: tunnelName + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Tunnel status. + * 404: + * description: Tunnel not found. + */ + app.get( + "/ssh/tunnel/status/:tunnelName", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const tunnelNameParam = req.params.tunnelName; + const tunnelName = Array.isArray(tunnelNameParam) + ? tunnelNameParam[0] + : tunnelNameParam; + const status = connectionStatus.get(tunnelName); + + if (!status) { + return res.status(404).json({ error: "Tunnel not found" }); + } + + res.json({ name: tunnelName, status }); + }, + ); + + /** + * @openapi + * /ssh/tunnel/connect: + * post: + * summary: Connect SSH tunnel + * description: Establishes an SSH tunnel connection with the specified configuration. + * tags: + * - SSH Tunnels + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * sourceHostId: + * type: integer + * tunnelIndex: + * type: integer + * responses: + * 200: + * description: Connection request received. + * 400: + * description: Invalid tunnel configuration. + * 401: + * description: Authentication required. + * 403: + * description: Access denied to this host. + * 500: + * description: Failed to connect tunnel. + */ + app.post( + "/ssh/tunnel/connect", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const tunnelConfig: TunnelConfig = req.body; + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (!tunnelConfig || !tunnelConfig.name) { + return res.status(400).json({ error: "Invalid tunnel configuration" }); + } + + const tunnelName = tunnelConfig.name; + tunnelConfig.requestingUserId = userId; + + try { + if (!validateTunnelConfig(tunnelName, tunnelConfig)) { + tunnelLogger.error(`Tunnel config validation failed`, { + operation: "tunnel_connect", + tunnelName, + configHostId: tunnelConfig.sourceHostId, + configTunnelIndex: tunnelConfig.tunnelIndex, + }); + return res.status(400).json({ + error: "Tunnel configuration does not match tunnel name", + }); + } + + if (tunnelConfig.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + tunnelConfig.sourceHostId, + "connect", + ); + + if (!accessInfo.hasAccess) { + tunnelLogger.warn("User attempted tunnel connect without access", { + operation: "tunnel_connect_unauthorized", + userId, + hostId: tunnelConfig.sourceHostId, + tunnelName, + }); + return res + .status(403) + .json({ error: "Access denied to this host" }); + } + } + + if (pendingTunnelOperations.has(tunnelName)) { + try { + await pendingTunnelOperations.get(tunnelName); + } catch { + tunnelLogger.warn(`Previous tunnel operation failed`, { + tunnelName, + }); + } + } + + const operation = (async () => { + manualDisconnects.delete(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + await cleanupTunnelResources(tunnelName); + + if (tunnelConfigs.has(tunnelName)) { + const existingConfig = tunnelConfigs.get(tunnelName); + if ( + existingConfig && + (existingConfig.sourceHostId !== tunnelConfig.sourceHostId || + existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex) + ) { + throw new Error(`Tunnel name collision detected: ${tunnelName}`); + } + } + + if ( + !isSingleHostTunnel(tunnelConfig) && + (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) + ) { + try { + const systemCrypto = SystemCrypto.getInstance(); + const internalAuthToken = + await systemCrypto.getInternalAuthToken(); + + const allHostsResponse = await axios.get( + "http://localhost:30001/host/db/host/internal/all", + { + headers: { + "Content-Type": "application/json", + "X-Internal-Auth-Token": internalAuthToken, + }, + }, + ); + + const allHosts: SSHHost[] = allHostsResponse.data || []; + const endpointHost = findHostByTunnelEndpoint( + allHosts, + tunnelConfig.endpointHost, + ); + + if (!endpointHost) { + if (getTunnelMode(tunnelConfig) !== "remote") { + tunnelConfig.endpointIP = + tunnelConfig.endpointIP || tunnelConfig.endpointHost; + } else { + throw new Error( + `Endpoint host '${tunnelConfig.endpointHost}' not found in database`, + ); + } + } else { + if (!endpointHost.id) { + throw new Error("Endpoint host not found"); + } + + const endpointAccess = await permissionManager.canAccessHost( + userId, + endpointHost.id, + "connect", + ); + if (!endpointAccess.hasAccess) { + tunnelLogger.warn( + "User attempted tunnel connect without endpoint access", + { + operation: "tunnel_connect_endpoint_unauthorized", + userId, + hostId: endpointHost.id, + tunnelName, + }, + ); + throw new Error("Endpoint host not found"); + } + + tunnelConfig.endpointIP = endpointHost.ip; + tunnelConfig.endpointSSHPort = endpointHost.port; + tunnelConfig.endpointUsername = endpointHost.username; + tunnelConfig.endpointAuthMethod = endpointHost.authType; + tunnelConfig.endpointKeyType = endpointHost.keyType; + tunnelConfig.endpointCredentialId = + endpointHost.userId === userId + ? endpointHost.credentialId + : undefined; + tunnelConfig.endpointUserId = userId; + + // Resolve credentials server-side instead of from HTTP response + if (endpointHost.id) { + try { + const { resolveHostById } = + await import("../host-resolver.js"); + const resolved = await resolveHostById( + endpointHost.id, + userId, + ); + if (resolved) { + tunnelConfig.endpointPassword = resolved.password; + tunnelConfig.endpointSSHKey = resolved.key; + tunnelConfig.endpointKeyPassword = resolved.keyPassword; + } + } catch (credError) { + tunnelLogger.warn( + "Failed to resolve endpoint credentials from DB", + { + operation: "tunnel_endpoint_credential_resolve", + endpointHostId: endpointHost.id, + error: + credError instanceof Error + ? credError.message + : "Unknown", + }, + ); + } + } + } + } catch (resolveError) { + tunnelLogger.error( + "Failed to resolve endpoint host", + resolveError, + { + operation: "tunnel_connect_resolve_endpoint_failed", + tunnelName, + endpointHost: tunnelConfig.endpointHost, + }, + ); + throw new Error( + `Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`, + { cause: resolveError }, + ); + } + } + + tunnelConfigs.set(tunnelName, tunnelConfig); + await connectSSHTunnel(tunnelConfig, 0); + })(); + + pendingTunnelOperations.set(tunnelName, operation); + + res.json({ message: "Connection request received", tunnelName }); + + operation + .catch((err) => { + tunnelLogger.error("Tunnel operation failed", err, { + operation: "tunnel_operation_failed", + tunnelName, + }); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: err instanceof Error ? err.message : "Unknown error", + }); + tunnelConnecting.delete(tunnelName); + }) + .finally(() => { + pendingTunnelOperations.delete(tunnelName); + }); + } catch (error) { + tunnelLogger.error("Failed to process tunnel connect", error, { + operation: "tunnel_connect", + tunnelName, + userId, + }); + res.status(500).json({ error: "Failed to connect tunnel" }); + } + }, + ); + + /** + * @openapi + * /ssh/tunnel/disconnect: + * post: + * summary: Disconnect SSH tunnel + * description: Disconnects an active SSH tunnel. + * tags: + * - SSH Tunnels + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * tunnelName: + * type: string + * responses: + * 200: + * description: Disconnect request received. + * 400: + * description: Tunnel name required. + * 401: + * description: Authentication required. + * 403: + * description: Access denied. + * 500: + * description: Failed to disconnect tunnel. + */ + app.post( + "/ssh/tunnel/disconnect", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const { tunnelName } = req.body; + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (!tunnelName) { + return res.status(400).json({ error: "Tunnel name required" }); + } + + try { + const config = tunnelConfigs.get(tunnelName); + if (config && config.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + config.sourceHostId, + "connect", + ); + if (!accessInfo.hasAccess) { + return res.status(403).json({ error: "Access denied" }); + } + } + + tunnelLogger.info("Tunnel stop request received", { + operation: "tunnel_stop_request", + userId, + hostId: config?.sourceHostId, + tunnelName, + }); + manualDisconnects.add(tunnelName); + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + await cleanupTunnelResources(tunnelName, true); + tunnelLogger.info("Tunnel cleanup completed", { + operation: "tunnel_cleanup_complete", + userId, + hostId: config?.sourceHostId, + tunnelName, + }); + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); + + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); + + setTimeout(() => { + manualDisconnects.delete(tunnelName); + }, 5000); + + res.json({ message: "Disconnect request received", tunnelName }); + } catch (error) { + tunnelLogger.error("Failed to disconnect tunnel", error, { + operation: "tunnel_disconnect", + tunnelName, + userId, + }); + res.status(500).json({ error: "Failed to disconnect tunnel" }); + } + }, + ); + + /** + * @openapi + * /ssh/tunnel/cancel: + * post: + * summary: Cancel tunnel retry + * description: Cancels the retry mechanism for a failed SSH tunnel connection. + * tags: + * - SSH Tunnels + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * tunnelName: + * type: string + * responses: + * 200: + * description: Cancel request received. + * 400: + * description: Tunnel name required. + * 401: + * description: Authentication required. + * 403: + * description: Access denied. + * 500: + * description: Failed to cancel tunnel retry. + */ + app.post( + "/ssh/tunnel/cancel", + authenticateJWT, + async (req: AuthenticatedRequest, res: Response) => { + const { tunnelName } = req.body; + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (!tunnelName) { + return res.status(400).json({ error: "Tunnel name required" }); + } + + try { + const config = tunnelConfigs.get(tunnelName); + if (config && config.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + config.sourceHostId, + "connect", + ); + if (!accessInfo.hasAccess) { + return res.status(403).json({ error: "Access denied" }); + } + } + + retryCounters.delete(tunnelName); + retryExhaustedTunnels.delete(tunnelName); + + if (activeRetryTimers.has(tunnelName)) { + clearTimeout(activeRetryTimers.get(tunnelName)!); + activeRetryTimers.delete(tunnelName); + } + + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } + + await cleanupTunnelResources(tunnelName, true); + + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true, + }); + + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); + + setTimeout(() => { + manualDisconnects.delete(tunnelName); + }, 5000); + + res.json({ message: "Cancel request received", tunnelName }); + } catch (error) { + tunnelLogger.error("Failed to cancel tunnel retry", error, { + operation: "tunnel_cancel", + tunnelName, + userId, + }); + res.status(500).json({ error: "Failed to cancel tunnel retry" }); + } + }, + ); +} diff --git a/src/backend/ssh/tunnel-socks5-relay.ts b/src/backend/hosts/tunnel/socks5-relay.ts similarity index 98% rename from src/backend/ssh/tunnel-socks5-relay.ts rename to src/backend/hosts/tunnel/socks5-relay.ts index fa615745..958ce7a6 100644 --- a/src/backend/ssh/tunnel-socks5-relay.ts +++ b/src/backend/hosts/tunnel/socks5-relay.ts @@ -1,5 +1,5 @@ import type { Duplex } from "stream"; -import { tunnelLogger } from "../utils/logger.js"; +import { tunnelLogger } from "../../utils/logger.js"; function parseSocksAddress(buffer: Buffer): { address: string; diff --git a/src/backend/ssh/tunnel-ssh-primitives.ts b/src/backend/hosts/tunnel/ssh-primitives.ts similarity index 97% rename from src/backend/ssh/tunnel-ssh-primitives.ts rename to src/backend/hosts/tunnel/ssh-primitives.ts index 62b1bed0..96d2abbf 100644 --- a/src/backend/ssh/tunnel-ssh-primitives.ts +++ b/src/backend/hosts/tunnel/ssh-primitives.ts @@ -1,7 +1,7 @@ import { Client, type ClientChannel } from "ssh2"; import type { Duplex } from "stream"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { tunnelLogger } from "../utils/logger.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { tunnelLogger } from "../../utils/logger.js"; export function getManagedTunnelAlgorithms() { return { diff --git a/src/backend/ssh/tunnel-utils.ts b/src/backend/hosts/tunnel/utils.ts similarity index 96% rename from src/backend/ssh/tunnel-utils.ts rename to src/backend/hosts/tunnel/utils.ts index 1d879272..d489e447 100644 --- a/src/backend/ssh/tunnel-utils.ts +++ b/src/backend/hosts/tunnel/utils.ts @@ -1,5 +1,5 @@ -import type { ErrorType, TunnelConfig } from "../../types/index.js"; -import { tunnelLogger } from "../utils/logger.js"; +import type { ErrorType, TunnelConfig } from "../../../types/index.js"; +import { tunnelLogger } from "../../utils/logger.js"; export function classifyTunnelError(errorMessage: string): ErrorType { if (!errorMessage) return "UNKNOWN"; diff --git a/src/backend/ssh/vault-oidc-auth.ts b/src/backend/hosts/vault-oidc-auth.ts similarity index 89% rename from src/backend/ssh/vault-oidc-auth.ts rename to src/backend/hosts/vault-oidc-auth.ts index 29474d96..ce10c385 100644 --- a/src/backend/ssh/vault-oidc-auth.ts +++ b/src/backend/hosts/vault-oidc-auth.ts @@ -11,9 +11,10 @@ // ephemeral key, cache the cert, and notify the browser to reconnect. import { WebSocket } from "ws"; -import { eq } from "drizzle-orm"; -import { getDb } from "../database/db/index.js"; -import { hosts, vaultProfiles } from "../database/db/schema.js"; +import { + createCurrentHostResolutionRepository, + createCurrentVaultProfileRepository, +} from "../database/repositories/factory.js"; import { sshLogger } from "../utils/logger.js"; import { type VaultProfileConfig, @@ -61,23 +62,20 @@ function rowToProfileConfig(row: Record): VaultProfileConfig { /** Load the Vault profile referenced by a host, or null if not configured. */ export async function loadVaultProfileForHost( hostId: number, + userId: string, ): Promise { - const db = getDb(); - const hostRows = await db - .select() - .from(hosts) - .where(eq(hosts.id, hostId)) - .limit(1); - if (!hostRows.length || hostRows[0].vaultProfileId == null) return null; + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); + if (!host || host.vaultProfileId == null) return null; - const profileRows = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, hostRows[0].vaultProfileId as number)) - .limit(1); - if (!profileRows.length) return null; + const profile = await createCurrentVaultProfileRepository().findById( + host.vaultProfileId as number, + ); + if (!profile) return null; - return rowToProfileConfig(profileRows[0] as Record); + return rowToProfileConfig(profile as Record); } /** diff --git a/src/backend/ssh/vault-signer-auth.ts b/src/backend/hosts/vault-signer-auth.ts similarity index 70% rename from src/backend/ssh/vault-signer-auth.ts rename to src/backend/hosts/vault-signer-auth.ts index 457dcd9e..4e0eba2f 100644 --- a/src/backend/ssh/vault-signer-auth.ts +++ b/src/backend/hosts/vault-signer-auth.ts @@ -16,10 +16,8 @@ // The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and // is re-exported here so callers have a single import surface. -import { and, eq } from "drizzle-orm"; -import { getDb } from "../database/db/index.js"; -import { vaultTokens } from "../database/db/schema.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { parseCertValidBefore } from "./vault-signer-core.js"; @@ -52,8 +50,7 @@ export async function storeVaultCert( privateKey: string, signedCert: string, ): Promise { - const db = getDb(); - const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } @@ -77,24 +74,13 @@ export async function storeVaultCert( "private_key", ); - await db - .insert(vaultTokens) - .values({ - userId, - profileId, - sshCert: encryptedCert, - privateKey: encryptedKey, - expiresAt, - }) - .onConflictDoUpdate({ - target: [vaultTokens.userId, vaultTokens.profileId], - set: { - sshCert: encryptedCert, - privateKey: encryptedKey, - expiresAt, - createdAt: new Date().toISOString(), - }, - }); + await createCurrentVaultTokenRepository().upsert({ + userId, + profileId, + sshCert: encryptedCert, + privateKey: encryptedKey, + expiresAt, + }); return expiresAt; } @@ -107,17 +93,10 @@ export async function getVaultCert( userId: string, profileId: number, ): Promise<{ privateKey: string; sshCert: string } | null> { - const db = getDb(); - const rows = await db - .select() - .from(vaultTokens) - .where( - and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), - ) - .limit(1); + const repository = createCurrentVaultTokenRepository(); + const row = await repository.findByUserAndProfile(userId, profileId); - if (!rows || rows.length === 0) return null; - const row = rows[0]; + if (!row) return null; const expiresMs = new Date(row.expiresAt).getTime(); if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) { @@ -125,7 +104,7 @@ export async function getVaultCert( return null; } - const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } @@ -144,12 +123,7 @@ export async function getVaultCert( "private_key", ); - await db - .update(vaultTokens) - .set({ lastUsed: new Date().toISOString() }) - .where( - and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), - ); + await repository.updateLastUsed(userId, profileId); return { privateKey, sshCert }; } @@ -158,10 +132,8 @@ export async function deleteVaultCert( userId: string, profileId: number, ): Promise { - const db = getDb(); - await db - .delete(vaultTokens) - .where( - and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), - ); + await createCurrentVaultTokenRepository().deleteByUserAndProfile( + userId, + profileId, + ); } diff --git a/src/backend/ssh/vault-signer-core.ts b/src/backend/hosts/vault-signer-core.ts similarity index 100% rename from src/backend/ssh/vault-signer-core.ts rename to src/backend/hosts/vault-signer-core.ts diff --git a/src/backend/hosts/vault-ssh-connect.ts b/src/backend/hosts/vault-ssh-connect.ts new file mode 100644 index 00000000..61637e85 --- /dev/null +++ b/src/backend/hosts/vault-ssh-connect.ts @@ -0,0 +1,39 @@ +import type { Client, ConnectConfig } from "ssh2"; + +type VaultAuthHost = { + id: number; + username: string; + userId?: string | null; + vaultProfile?: { id?: number | null } | null; +}; + +export async function setupVaultSshSignerAuth( + config: ConnectConfig, + client: Client, + host: VaultAuthHost, +): Promise { + if (!host.userId) { + throw new Error("Vault SSH signer authentication requires a user session"); + } + + const vaultProfileId = host.vaultProfile?.id; + if (!vaultProfileId) { + throw new Error("Host has no Vault signer profile configured"); + } + + const { getVaultCert } = await import("./vault-signer-auth.js"); + const cert = await getVaultCert(host.userId, vaultProfileId); + if (!cert) { + throw new Error( + "Vault SSH signer authentication required. Please open a Terminal connection first.", + ); + } + + const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + await setupOPKSSHCertAuth( + config, + client, + { privateKey: cert.privateKey, sshCert: cert.sshCert }, + host.username, + ); +} diff --git a/src/backend/dashboard.ts b/src/backend/services/dashboard.ts similarity index 69% rename from src/backend/dashboard.ts rename to src/backend/services/dashboard.ts index 70733745..6c2d903c 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/services/dashboard.ts @@ -1,19 +1,17 @@ import express from "express"; import cookieParser from "cookie-parser"; -import { createCorsMiddleware } from "./utils/cors-config.js"; -import { getDb } from "./database/db/index.js"; +import { createCorsMiddleware } from "../utils/cors-config.js"; +import { dashboardLogger } from "../utils/logger.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import type { AuthenticatedRequest } from "../../types/index.js"; +import { dashboardServiceLinksRouter } from "../database/routes/dashboard-service-links-routes.js"; import { - recentActivity, - hosts, - hostAccess, - userRoles, -} from "./database/db/schema.js"; -import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm"; -import { dashboardLogger } from "./utils/logger.js"; -import { SimpleDBOps } from "./utils/simple-db-ops.js"; -import { AuthManager } from "./utils/auth-manager.js"; -import type { AuthenticatedRequest } from "../types/index.js"; -import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js"; + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRecentActivityRepository, + createCurrentRoleRepository, +} from "../database/repositories/factory.js"; +import { DataCrypto } from "../utils/data-crypto.js"; const app = express(); const authManager = AuthManager.getInstance(); @@ -23,6 +21,10 @@ const serverStartTime = Date.now(); const activityRateLimiter = new Map(); const RATE_LIMIT_MS = 1000; +function isUserDataUnlocked(userId: string): boolean { + return DataCrypto.getUserDataKey(userId) !== null; +} + app.use(createCorsMiddleware()); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); @@ -103,7 +105,7 @@ app.get("/activity/recent", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (!isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -112,16 +114,8 @@ app.get("/activity/recent", async (req, res) => { const limit = Number(req.query.limit) || 20; - const activities = await SimpleDBOps.select( - getDb() - .select() - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)) - .limit(limit), - "recent_activity", - userId, - ); + const activities = + await createCurrentRecentActivityRepository().listByUserId(userId, limit); res.json(activities); } catch (err) { @@ -168,7 +162,7 @@ app.post("/activity/log", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (!isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -223,97 +217,45 @@ app.post("/activity/log", async (req, res) => { entriesToDelete.forEach((key) => activityRateLimiter.delete(key)); } - const ownedHosts = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const isOwnedHost = + await createCurrentHostResolutionRepository().isHostOwnedByUser( + hostId, + userId, + ); - if (ownedHosts.length === 0) { - const userRoleIds = await getDb() - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); - - const now = new Date().toISOString(); - const sharedHosts = await getDb() - .select() - .from(hostAccess) - .where( - and( - eq(hostAccess.hostId, hostId), - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? sql`${hostAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), + if (!isOwnedHost) { + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const sharedHosts = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, ); + const hasSharedAccess = sharedHosts.some( + (access) => access.hostId === hostId, + ); - if (sharedHosts.length === 0) { + if (!hasSharedAccess) { return res .status(404) .json({ error: "Host not found or access denied" }); } } - const result = (await SimpleDBOps.insert( - recentActivity, - "recent_activity", - { - userId, - type, - hostId, - hostName, - }, + const result = await createCurrentRecentActivityRepository().create({ userId, - )) as unknown as { id: number }; + type, + hostId, + hostName, + }); // Best-effort trim of old activity entries; failures here should not // cause the primary /activity/log request to 500. try { - const allActivities = await SimpleDBOps.select<{ - id: number; - timestamp: string; - }>( - getDb() - .select({ - id: recentActivity.id, - timestamp: recentActivity.timestamp, - }) - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)), - "recent_activity", + await createCurrentRecentActivityRepository().trimUserActivity( userId, + 100, ); - - if (allActivities.length > 100) { - const idsToDelete = allActivities - .slice(100) - .map((a) => a.id) - .filter((id) => typeof id === "number"); - - if (idsToDelete.length > 0) { - await SimpleDBOps.delete( - recentActivity, - "recent_activity", - and( - eq(recentActivity.userId, userId), - inArray(recentActivity.id, idsToDelete), - ), - ); - } - } } catch (trimErr) { dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", { operation: "trim_recent_activity", @@ -349,18 +291,14 @@ app.delete("/activity/reset", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (!isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } - await SimpleDBOps.delete( - recentActivity, - "recent_activity", - eq(recentActivity.userId, userId), - ); + await createCurrentRecentActivityRepository().deleteByUserId(userId); dashboardLogger.success("Recent activity cleared", { operation: "reset_recent_activity", diff --git a/src/backend/homepage.ts b/src/backend/services/homepage.ts similarity index 55% rename from src/backend/homepage.ts rename to src/backend/services/homepage.ts index 01afd6c4..12e3b8b3 100644 --- a/src/backend/homepage.ts +++ b/src/backend/services/homepage.ts @@ -1,13 +1,13 @@ import express from "express"; import cookieParser from "cookie-parser"; -import { createCorsMiddleware } from "./utils/cors-config.js"; -import { AuthManager } from "./utils/auth-manager.js"; -import { homepageItemsRouter } from "./database/routes/homepage-items-routes.js"; -import { homepageLayoutRouter } from "./database/routes/homepage-layout-routes.js"; -import { homepageFaviconRouter } from "./database/routes/homepage-favicon-routes.js"; -import { homepageRssRouter } from "./database/routes/homepage-rss-routes.js"; -import { homepagePingRouter } from "./database/routes/homepage-ping-routes.js"; -import { homepageProxyRouter } from "./database/routes/homepage-proxy-routes.js"; +import { createCorsMiddleware } from "../utils/cors-config.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import { homepageItemsRouter } from "../database/routes/homepage-items-routes.js"; +import { homepageLayoutRouter } from "../database/routes/homepage-layout-routes.js"; +import { homepageFaviconRouter } from "../database/routes/homepage-favicon-routes.js"; +import { homepageRssRouter } from "../database/routes/homepage-rss-routes.js"; +import { homepagePingRouter } from "../database/routes/homepage-ping-routes.js"; +import { homepageProxyRouter } from "../database/routes/homepage-proxy-routes.js"; const app = express(); const authManager = AuthManager.getInstance(); diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts deleted file mode 100644 index a3cefe33..00000000 --- a/src/backend/ssh/docker.ts +++ /dev/null @@ -1,2316 +0,0 @@ -import express from "express"; -import { createCorsMiddleware } from "../utils/cors-config.js"; -import cookieParser from "cookie-parser"; -import axios from "axios"; -import { Client as SSHClient } from "ssh2"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { logger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import type { SSHHost, ProxyNode } from "../../types/index.js"; -import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { registerDockerContainerRoutes } from "./docker-container-routes.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; -import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; -import { - containerCommand, - getContainerRuntimeConfig, - getRuntimeLabel, - type ContainerRuntime, -} from "./container-runtime.js"; - -const sshLogger = logger; - -function createConnectionLog( - type: "info" | "success" | "warning" | "error", - stage: ConnectionStage, - message: string, - details?: Record, -): Omit { - return { - type, - stage, - message, - details, - }; -} - -interface SSHSession { - client: SSHClient; - isConnected: boolean; - lastActive: number; - timeout?: NodeJS.Timeout; - activeOperations: number; - hostId?: number; - userId?: string; - isWindows?: boolean; - containerRuntime?: ContainerRuntime; -} - -interface PendingTOTPSession { - client: SSHClient; - finish: (responses: string[]) => void; - config: Record; - createdAt: number; - sessionId: string; - hostId?: number; - ip?: string; - port?: number; - username?: string; - userId?: string; - prompts?: Array<{ prompt: string; echo: boolean }>; - totpPromptIndex?: number; - resolvedPassword?: string; - totpAttempts: number; - isWarpgate?: boolean; - containerRuntime?: ContainerRuntime; -} - -const sshSessions: Record = {}; -const pendingTOTPSessions: Record = {}; - -const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000; - -setInterval(() => { - const now = Date.now(); - Object.keys(pendingTOTPSessions).forEach((sessionId) => { - const session = pendingTOTPSessions[sessionId]; - if (now - session.createdAt > 180000) { - try { - session.client.end(); - } catch { - // expected - } - delete pendingTOTPSessions[sessionId]; - } - }); -}, 60000); - -function cleanupSession(sessionId: string) { - const session = sshSessions[sessionId]; - if (session) { - if (session.activeOperations > 0) { - sshLogger.warn( - `Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`, - { - operation: "cleanup_deferred", - sessionId, - activeOperations: session.activeOperations, - }, - ); - scheduleSessionCleanup(sessionId); - return; - } - - try { - session.client.end(); - } catch { - // expected - } - clearTimeout(session.timeout); - delete sshSessions[sessionId]; - } -} - -function scheduleSessionCleanup(sessionId: string) { - const session = sshSessions[sessionId]; - if (session) { - if (session.timeout) clearTimeout(session.timeout); - - session.timeout = setTimeout(() => { - cleanupSession(sessionId); - }, SESSION_IDLE_TIMEOUT); - } -} - -interface JumpHostConfig { - id: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - credentialId?: number; - useSocks5?: boolean | null; - socks5Host?: string | null; - socks5Port?: number | null; - socks5Username?: string | null; - socks5Password?: string | null; - socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; - [key: string]: unknown; -} - -async function resolveJumpHost( - hostId: number, - userId: string, -): Promise { - try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); - - if (hostResults.length === 0) { - return null; - } - - const host = hostResults[0]; - const ownerId = (host.userId || userId) as string; - - if (host.credentialId) { - if (userId !== ownerId) { - try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { - return { - ...host, - password: sharedCred.password, - key: sharedCred.key, - keyPassword: sharedCred.keyPassword, - keyType: sharedCred.keyType, - authType: sharedCred.key - ? "key" - : sharedCred.password - ? "password" - : "none", - } as JumpHostConfig; - } - } catch { - // fall through to owner credential lookup - } - } - - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", - ownerId, - ); - - if (credentials.length > 0) { - const credential = credentials[0]; - return { - ...host, - password: credential.password as string | undefined, - key: (credential.key || credential.privateKey) as string | undefined, - keyPassword: credential.keyPassword as string | undefined, - keyType: credential.keyType as string | undefined, - authType: credential.authType as string | undefined, - } as JumpHostConfig; - } - } - - return host as JumpHostConfig; - } catch (error) { - sshLogger.error("Failed to resolve jump host", error, { - operation: "resolve_jump_host", - hostId, - userId, - }); - return null; - } -} - -async function createJumpHostChain( - jumpHosts: Array<{ hostId: number }>, - userId: string, - socks5Config?: SOCKS5Config | null, -): Promise { - if (!jumpHosts || jumpHosts.length === 0) { - return null; - } - - let currentClient: SSHClient | null = null; - const clients: SSHClient[] = []; - - try { - const jumpHostConfigs = await Promise.all( - jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), - ); - - const totalHops = jumpHostConfigs.length; - - for (let i = 0; i < jumpHostConfigs.length; i++) { - if (!jumpHostConfigs[i]) { - sshLogger.error(`Jump host ${i + 1} not found`, undefined, { - operation: "jump_host_chain", - hostId: jumpHosts[i].hostId, - hopIndex: i, - totalHops, - }); - clients.forEach((c) => c.end()); - return null; - } - } - - const firstHopSocks5Config = getJumpHostSocks5Config( - jumpHostConfigs[0], - socks5Config, - ); - let proxySocket: import("net").Socket | null = null; - if (firstHopSocks5Config?.useSocks5) { - const firstHop = jumpHostConfigs[0]!; - proxySocket = await createSocks5Connection( - firstHop.ip, - firstHop.port || 22, - firstHopSocks5Config, - ); - } - - for (let i = 0; i < jumpHostConfigs.length; i++) { - const jumpHostConfig = jumpHostConfigs[i]!; - - const jumpClient = new SSHClient(); - clients.push(jumpClient); - - const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( - jumpHostConfig.id, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - null, - userId, - true, - ); - - // eslint-disable-next-line no-async-promise-executor - const connected = await new Promise(async (resolve) => { - const timeout = setTimeout(() => { - resolve(false); - }, 30000); - - jumpClient.on("ready", () => { - clearTimeout(timeout); - resolve(true); - }); - - jumpClient.on("error", (err) => { - clearTimeout(timeout); - sshLogger.error( - `Jump host ${i + 1}/${totalHops} connection failed`, - err, - { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - hopIndex: i, - totalHops, - previousHop: - i > 0 - ? jumpHostConfigs[i - 1]?.ip - : proxySocket - ? "proxy" - : "direct", - usedProxySocket: i === 0 && !!proxySocket, - }, - ); - resolve(false); - }); - - const connectConfig: Record = { - host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, - port: jumpHostConfig.port || 22, - username: jumpHostConfig.username, - tryKeyboard: - jumpHostConfig.authType !== "none" && - jumpHostConfig.authType !== "tailscale", - readyTimeout: 60000, - hostVerifier: jumpHostVerifier, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group18-sha512", - "diffie-hellman-group17-sha512", - "diffie-hellman-group16-sha512", - "diffie-hellman-group15-sha512", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { - connectConfig.password = jumpHostConfig.password; - } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { - const cleanKey = jumpHostConfig.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); - if (jumpHostConfig.keyPassword) { - connectConfig.passphrase = jumpHostConfig.keyPassword; - } - } else if (jumpHostConfig.authType === "agent") { - const result = await applyAgentAuth( - connectConfig, - jumpHostConfig.terminalConfig as - | Record - | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } - } - - jumpClient.on( - "keyboard-interactive", - ( - _name: string, - _instructions: string, - _lang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && jumpHostConfig.password) { - return jumpHostConfig.password as string; - } - return ""; - }); - finish(responses); - }, - ); - - if (currentClient) { - currentClient.forwardOut( - "127.0.0.1", - 0, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - (err, stream) => { - if (err) { - clearTimeout(timeout); - resolve(false); - return; - } - connectConfig.sock = stream; - jumpClient.connect(connectConfig); - }, - ); - } else if (proxySocket) { - connectConfig.sock = proxySocket; - jumpClient.connect(connectConfig); - } else { - jumpClient.connect(connectConfig); - } - }); - - if (!connected) { - clients.forEach((c) => c.end()); - return null; - } - - currentClient = jumpClient; - } - - return currentClient; - } catch (error) { - sshLogger.error("Failed to create jump host chain", error, { - operation: "jump_host_chain", - }); - clients.forEach((c) => c.end()); - return null; - } -} - -async function executeDockerCommand( - session: SSHSession, - command: string, - sessionId?: string, - userId?: string, - hostId?: number, -): Promise { - const startTime = Date.now(); - sshLogger.info("Executing Docker command", { - operation: "docker_command_exec", - sessionId, - userId, - hostId, - command: command.split(" ")[1], - }); - return new Promise((resolve, reject) => { - session.client.exec(command, (err, stream) => { - if (err) { - sshLogger.error("Docker command execution error", err, { - operation: "execute_docker_command", - sessionId, - userId, - hostId, - command: command.split(" ")[1], - }); - return reject(err); - } - - let stdout = ""; - let stderr = ""; - - stream.on("close", (code: number) => { - if (code !== 0) { - sshLogger.error("Docker command failed", undefined, { - operation: "execute_docker_command", - sessionId, - userId, - hostId, - command: command.split(" ")[1], - exitCode: code, - stderr, - }); - reject(new Error(stderr || `Command exited with code ${code}`)); - } else { - sshLogger.success("Docker command completed", { - operation: "docker_command_success", - sessionId, - userId, - hostId, - command: command.split(" ")[1], - duration: Date.now() - startTime, - }); - resolve(stdout); - } - }); - - stream.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - - stream.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - stream.on("error", (streamErr: Error) => { - sshLogger.error("Docker command stream error", streamErr, { - operation: "execute_docker_command", - sessionId, - userId, - hostId, - command: command.split(" ")[1], - }); - reject(streamErr); - }); - }); - }); -} - -const app = express(); - -app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); - -app.use(cookieParser()); -app.use(express.json({ limit: "100mb" })); -app.use(express.urlencoded({ limit: "100mb", extended: true })); -app.use((_req, res, next) => { - res.setHeader("Cache-Control", "no-store"); - next(); -}); - -const authManager = AuthManager.getInstance(); -app.use(authManager.createAuthMiddleware()); - -const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; -const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/; - -function getRequestUserId(req: express.Request): string | undefined { - return (req as AuthenticatedRequest).userId; -} - -app.param("containerId", (req, res, next, value) => { - if (!CONTAINER_ID_RE.test(value)) { - return res.status(400).json({ error: "Invalid container ID" }); - } - next(); -}); - -/** - * @openapi - * /docker/ssh/connect: - * post: - * summary: Establish SSH session for Docker - * description: Establishes an SSH session to a host for Docker operations. - * tags: - * - Docker - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * responses: - * 200: - * description: SSH connection established. - * 400: - * description: Missing sessionId or hostId. - * 401: - * description: Authentication required. - * 403: - * description: Docker is not enabled for this host. - * 404: - * description: Host not found. - * 500: - * description: SSH connection failed. - */ -app.post("/docker/ssh/connect", async (req, res) => { - const { - sessionId, - hostId, - userProvidedPassword, - userProvidedSshKey, - userProvidedKeyPassword, - useSocks5, - socks5Host, - socks5Port, - socks5Username, - socks5Password, - socks5ProxyChain, - } = req.body; - const userId = getRequestUserId(req); - - const connectionLogs: Array> = []; - - if (!userId) { - sshLogger.error("Docker SSH connection rejected: no authenticated user", { - operation: "docker_connect_auth", - sessionId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_connecting", - "Authentication required", - ), - ); - return res - .status(401) - .json({ error: "Authentication required", connectionLogs }); - } - - if (!SimpleDBOps.isUserDataUnlocked(userId)) { - connectionLogs.push( - createConnectionLog("error", "docker_connecting", "Session expired"), - ); - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - connectionLogs, - }); - } - - if (!sessionId || !hostId) { - sshLogger.warn("Missing Docker SSH connection parameters", { - operation: "docker_connect", - sessionId, - hasHostId: !!hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_connecting", - "Missing connection parameters", - ), - ); - return res - .status(400) - .json({ error: "Missing sessionId or hostId", connectionLogs }); - } - - connectionLogs.push( - createConnectionLog( - "info", - "docker_connecting", - "Initiating Docker SSH connection", - ), - ); - - try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); - - if (hostResults.length === 0) { - connectionLogs.push( - createConnectionLog("error", "docker_connecting", "Host not found"), - ); - return res.status(404).json({ error: "Host not found", connectionLogs }); - } - - const host = hostResults[0] as unknown as SSHHost; - - if (host.userId !== userId) { - const { PermissionManager } = - await import("../utils/permission-manager.js"); - const permissionManager = PermissionManager.getInstance(); - const accessInfo = await permissionManager.canAccessHost( - userId, - hostId, - "execute", - ); - - if (!accessInfo.hasAccess) { - sshLogger.warn("User does not have access to host", { - operation: "docker_connect", - hostId, - userId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_connecting", - "Access denied to host", - ), - ); - return res.status(403).json({ error: "Access denied", connectionLogs }); - } - } - if (typeof host.jumpHosts === "string" && host.jumpHosts) { - try { - host.jumpHosts = JSON.parse(host.jumpHosts); - } catch (e) { - sshLogger.error("Failed to parse jump hosts", e, { - hostId: host.id, - }); - host.jumpHosts = []; - } - } - if (typeof host.terminalConfig === "string" && host.terminalConfig) { - try { - host.terminalConfig = JSON.parse(host.terminalConfig as string); - } catch { - host.terminalConfig = undefined; - } - } - const { runtime: containerRuntime } = getContainerRuntimeConfig( - host.dockerConfig, - ); - - if (!host.enableDocker) { - sshLogger.warn("Docker not enabled for host", { - operation: "docker_connect", - hostId, - userId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_connecting", - "Docker is not enabled for this host", - ), - ); - return res.status(403).json({ - error: - "Docker is not enabled for this host. Enable it in Host Settings.", - code: "DOCKER_DISABLED", - connectionLogs, - }); - } - - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Resolving authentication credentials", - ), - ); - - if (sshSessions[sessionId]) { - cleanupSession(sessionId); - } - - if (pendingTOTPSessions[sessionId]) { - try { - pendingTOTPSessions[sessionId].client.end(); - } catch { - // expected - } - delete pendingTOTPSessions[sessionId]; - } - - let resolvedCredentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - authType?: string; - } = { - password: host.password, - sshKey: host.key, - keyPassword: host.keyPassword, - authType: host.authType, - }; - - if (userProvidedPassword) { - resolvedCredentials.password = userProvidedPassword; - } - if (userProvidedSshKey) { - resolvedCredentials.sshKey = userProvidedSshKey; - resolvedCredentials.authType = "key"; - } - if (userProvidedKeyPassword) { - resolvedCredentials.keyPassword = userProvidedKeyPassword; - } - - if (host.credentialId) { - const ownerId = host.userId; - - if (userId !== ownerId) { - try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - host.id, - userId, - ); - - if (sharedCred) { - resolvedCredentials = { - password: sharedCred.password, - sshKey: sharedCred.key, - keyPassword: sharedCred.keyPassword, - authType: sharedCred.authType, - }; - } - } catch (error) { - sshLogger.error("Failed to resolve shared credential", error, { - operation: "docker_connect", - hostId, - userId, - }); - } - } else { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); - - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedCredentials = { - password: credential.password as string | undefined, - sshKey: (credential.key || credential.privateKey) as - | string - | undefined, - keyPassword: credential.keyPassword as string | undefined, - authType: credential.authType as string | undefined, - }; - } - } - } - - const client = new SSHClient(); - - const config: Record = { - host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, - port: host.port || 22, - username: host.username, - tryKeyboard: true, - keepaliveInterval: - typeof host.terminalConfig?.keepaliveInterval === "number" - ? host.terminalConfig.keepaliveInterval * 1000 - : 60000, - keepaliveCountMax: - typeof host.terminalConfig?.keepaliveCountMax === "number" - ? host.terminalConfig.keepaliveCountMax - : 5, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - hostId, - host.ip, - host.port || 22, - null, - userId, - false, - ), - }; - - if ( - resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" || - resolvedCredentials.authType === "warpgate" - ) { - // Tailscale SSH, "none", and Warpgate auth: no static credentials - } else if (resolvedCredentials.authType === "password") { - if (resolvedCredentials.password) { - config.password = resolvedCredentials.password; - } - } else if (resolvedCredentials.authType === "opkssh") { - try { - const { getOPKSSHToken } = await import("./opkssh-auth.js"); - const token = await getOPKSSHToken(userId, hostId); - - if (!token) { - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", - ), - ); - return res.status(401).json({ - error: - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", - requiresOPKSSHAuth: true, - connectionLogs, - }); - } - - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( - config as import("ssh2").ConnectConfig, - client, - token, - host.username, - ); - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Using OPKSSH certificate authentication", - ), - ); - } catch (opksshError) { - sshLogger.error("OPKSSH authentication error for Docker", { - operation: "docker_connect", - sessionId, - hostId, - error: - opksshError instanceof Error - ? opksshError.message - : "Unknown error", - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - `OPKSSH authentication failed: ${opksshError instanceof Error ? opksshError.message : "Unknown error"}`, - ), - ); - return res.status(500).json({ - error: "OPKSSH authentication failed", - connectionLogs, - }); - } - } else if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.sshKey - ) { - try { - config.privateKey = preparePrivateKeyForSSH2( - resolvedCredentials.sshKey, - resolvedCredentials.keyPassword, - ); - if (resolvedCredentials.keyPassword) { - config.passphrase = resolvedCredentials.keyPassword; - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Invalid private key format"; - sshLogger.error("SSH key processing error", error, { - operation: "docker_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - `SSH key processing error: ${message}`, - ), - ); - return res.status(400).json({ - error: `SSH key format error: ${message}`, - connectionLogs, - }); - } - } else if (resolvedCredentials.authType === "key") { - sshLogger.error("SSH key authentication requested but no key provided", { - operation: "docker_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - "SSH key authentication requested but no key provided", - ), - ); - return res.status(400).json({ - error: "SSH key authentication requested but no key provided", - connectionLogs, - }); - } else if (resolvedCredentials.authType === "agent") { - const result = await applyAgentAuth( - config, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - connectionLogs.push( - createConnectionLog("error", "docker_auth", result.error), - ); - return res.status(400).json({ error: result.error, connectionLogs }); - } - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Using SSH agent authentication", - ), - ); - } - - let responseSent = false; - connectionLogs.push( - createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`), - ); - - connectionLogs.push( - createConnectionLog( - "info", - "tcp", - `Connecting to ${host.ip}:${host.port || 22}`, - ), - ); - - connectionLogs.push( - createConnectionLog("info", "handshake", "Initiating SSH handshake"), - ); - - if (resolvedCredentials.authType === "password") { - connectionLogs.push( - createConnectionLog("info", "auth", "Authenticating with password"), - ); - } else if (resolvedCredentials.authType === "key") { - connectionLogs.push( - createConnectionLog("info", "auth", "Authenticating with SSH key"), - ); - } else if (resolvedCredentials.authType === "agent") { - connectionLogs.push( - createConnectionLog("info", "auth", "Authenticating with SSH agent"), - ); - } else if ( - resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" - ) { - connectionLogs.push( - createConnectionLog( - "info", - "auth", - "Attempting keyboard-interactive authentication", - ), - ); - } - - client.on("ready", () => { - if (responseSent) return; - responseSent = true; - - connectionLogs.push( - createConnectionLog( - "success", - "connected", - "SSH connection established successfully", - ), - ); - - const session: SSHSession = { - client, - isConnected: true, - lastActive: Date.now(), - activeOperations: 0, - hostId, - userId, - containerRuntime, - }; - - sshSessions[sessionId] = session; - scheduleSessionCleanup(sessionId); - - client.exec("ver", (err, stream) => { - if (!err && stream) { - let output = ""; - stream.on("data", (d: Buffer) => { - output += d.toString(); - }); - stream.on("close", () => { - if (output.toLowerCase().includes("windows")) { - session.isWindows = true; - } - }); - stream.stderr.on("data", () => {}); - } - }); - - res.json({ - success: true, - message: "SSH connection established", - connectionLogs, - }); - }); - - client.on("error", (err) => { - if (responseSent) { - sshLogger.error( - "Docker SSH connection error after response sent", - err, - { - operation: "docker_connect_after_response", - sessionId, - hostId, - userId, - }, - ); - - if (pendingTOTPSessions[sessionId]) { - delete pendingTOTPSessions[sessionId]; - } - return; - } - responseSent = true; - - sshLogger.error("Docker SSH connection failed", err, { - operation: "docker_connect", - sessionId, - hostId, - userId, - }); - - let errorStage: ConnectionStage; - if ( - err.message.includes("ENOTFOUND") || - err.message.includes("getaddrinfo") - ) { - errorStage = "dns"; - connectionLogs.push( - createConnectionLog( - "error", - errorStage, - `DNS resolution failed: ${err.message}`, - ), - ); - } else if ( - err.message.includes("ECONNREFUSED") || - err.message.includes("ETIMEDOUT") - ) { - errorStage = "tcp"; - connectionLogs.push( - createConnectionLog( - "error", - errorStage, - `TCP connection failed: ${err.message}`, - ), - ); - } else if ( - err.message.includes("handshake") || - err.message.includes("key exchange") - ) { - errorStage = "handshake"; - connectionLogs.push( - createConnectionLog( - "error", - errorStage, - `SSH handshake failed: ${err.message}`, - ), - ); - } else if ( - err.message.includes("authentication") || - err.message.includes("Authentication") - ) { - errorStage = "auth"; - connectionLogs.push( - createConnectionLog( - "error", - errorStage, - `Authentication failed: ${err.message}`, - ), - ); - } else if (err.message.includes("verification failed")) { - errorStage = "handshake"; - connectionLogs.push( - createConnectionLog( - "error", - errorStage, - `SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`, - ), - ); - } else { - connectionLogs.push( - createConnectionLog( - "error", - "error", - `SSH connection failed: ${err.message}`, - ), - ); - } - - if ( - (resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale") && - (err.message.includes("authentication") || - err.message.includes("All configured authentication methods failed")) - ) { - res.json({ - status: "auth_required", - reason: "no_keyboard", - connectionLogs, - }); - } else { - res.status(500).json({ - success: false, - message: err.message || "SSH connection failed", - connectionLogs, - }); - } - }); - - client.on("close", () => { - if (sshSessions[sessionId]) { - sshSessions[sessionId].isConnected = false; - cleanupSession(sessionId); - } - - if (pendingTOTPSessions[sessionId]) { - delete pendingTOTPSessions[sessionId]; - } - }); - - client.on( - "keyboard-interactive", - ( - name: string, - instructions: string, - instructionsLang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const promptTexts = prompts.map((p) => p.prompt); - - const warpgatePattern = /warpgate\s+authentication/i; - const isWarpgate = - warpgatePattern.test(name) || - warpgatePattern.test(instructions) || - promptTexts.some((p) => warpgatePattern.test(p)); - - if (isWarpgate) { - const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; - const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); - const keyMatch = fullText.match( - /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, - ); - - if (urlMatch) { - if (responseSent) return; - responseSent = true; - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip: host.ip, - port: host.port || 22, - username: host.username, - userId, - prompts, - totpPromptIndex: -1, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - isWarpgate: true, - containerRuntime, - }; - - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Warpgate authentication required", - ), - ); - - res.json({ - requires_warpgate: true, - sessionId, - url: urlMatch[0], - securityKey: keyMatch ? keyMatch[1] : "N/A", - connectionLogs, - }); - return; - } - } - - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), - ); - - if (totpPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip: host.ip, - port: host.port || 22, - username: host.username, - userId, - prompts, - totpPromptIndex, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - containerRuntime, - }; - - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "TOTP verification required", - ), - ); - - res.json({ - requires_totp: true, - sessionId, - prompt: prompts[totpPromptIndex].prompt, - connectionLogs, - }); - } else { - const passwordPromptIndex = prompts.findIndex((p) => - /password/i.test(p.prompt), - ); - - if (resolvedCredentials.authType === "warpgate") { - finish(prompts.map(() => "")); - return; - } - - if ( - (resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale") && - passwordPromptIndex !== -1 - ) { - if (responseSent) return; - responseSent = true; - client.end(); - res.json({ - status: "auth_required", - reason: "no_keyboard", - }); - return; - } - - const hasStoredPassword = - resolvedCredentials.password && - resolvedCredentials.authType !== "none"; - - if (!hasStoredPassword && passwordPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if ( - /password/i.test(p.prompt) && - resolvedCredentials.password - ) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if ( - /password/i.test(p.prompt) && - resolvedCredentials.password - ) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip: host.ip, - port: host.port || 22, - username: host.username, - userId, - prompts, - totpPromptIndex: passwordPromptIndex, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - containerRuntime, - }; - - res.json({ - requires_totp: true, - sessionId, - prompt: prompts[passwordPromptIndex].prompt, - isPassword: true, - }); - return; - } - - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - } - }, - ); - - const proxyConfig: SOCKS5Config | null = - useSocks5 && - (socks5Host || - (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) - ? { - useSocks5, - socks5Host, - socks5Port, - socks5Username, - socks5Password, - socks5ProxyChain: socks5ProxyChain as ProxyNode[], - } - : null; - - const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0; - - if (hasJumpHosts) { - try { - if (proxyConfig) { - connectionLogs.push( - createConnectionLog( - "info", - "proxy", - "Connecting via proxy + jump hosts", - ), - ); - } - connectionLogs.push( - createConnectionLog( - "info", - "jump", - `Connecting via ${host.jumpHosts!.length} jump host(s)`, - ), - ); - const jumpClient = await createJumpHostChain( - host.jumpHosts as Array<{ hostId: number }>, - userId, - proxyConfig, - ); - - if (!jumpClient) { - connectionLogs.push( - createConnectionLog( - "error", - "jump", - "Failed to establish jump host chain", - ), - ); - return res.status(500).json({ - error: "Failed to establish jump host chain", - connectionLogs, - }); - } - - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port || 22, - (err, stream) => { - if (err) { - sshLogger.error("Failed to forward through jump host", err, { - operation: "docker_jump_forward", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Failed to forward through jump host: ${err.message}`, - ), - ); - jumpClient.end(); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: "Failed to forward through jump host: " + err.message, - connectionLogs, - }); - } - return; - } - - config.sock = stream; - client.connect(config); - }, - ); - } catch (jumpError) { - sshLogger.error("Jump host connection failed", jumpError, { - operation: "docker_jump_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Jump host connection failed: ${jumpError instanceof Error ? jumpError.message : "Unknown error"}`, - ), - ); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: - "Jump host connection failed: " + - (jumpError instanceof Error - ? jumpError.message - : "Unknown error"), - connectionLogs, - }); - } - return; - } - } else if (proxyConfig) { - connectionLogs.push( - createConnectionLog("info", "proxy", "Connecting via proxy"), - ); - try { - const proxySocket = await createSocks5Connection( - host.ip, - host.port || 22, - proxyConfig, - ); - if (proxySocket) { - config.sock = proxySocket; - } - client.connect(config); - } catch (proxyError) { - sshLogger.error("Proxy connection failed", proxyError, { - operation: "docker_proxy_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`, - ), - ); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: - "Proxy connection failed: " + - (proxyError instanceof Error - ? proxyError.message - : "Unknown error"), - connectionLogs, - }); - } - return; - } - } else { - client.connect(config); - } - } catch (error) { - sshLogger.error("Docker SSH connection error", error, { - operation: "docker_connect", - sessionId, - hostId, - userId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_connecting", - `Connection error: ${error instanceof Error ? error.message : "Unknown error"}`, - ), - ); - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : "Unknown error", - connectionLogs, - }); - } -}); - -/** - * @openapi - * /docker/ssh/disconnect: - * post: - * summary: Disconnect SSH session - * description: Closes an active SSH session for Docker operations. - * tags: - * - Docker - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * sessionId: - * type: string - * responses: - * 200: - * description: SSH session disconnected. - * 400: - * description: Session ID is required. - */ -app.post("/docker/ssh/disconnect", async (req, res) => { - const { sessionId } = req.body; - - if (!sessionId) { - return res.status(400).json({ error: "Session ID is required" }); - } - - cleanupSession(sessionId); - - res.json({ success: true, message: "SSH session disconnected" }); -}); - -/** - * @openapi - * /docker/ssh/connect-totp: - * post: - * summary: Verify TOTP and complete connection - * description: Verifies the TOTP code and completes the SSH connection. - * tags: - * - Docker - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * sessionId: - * type: string - * totpCode: - * type: string - * responses: - * 200: - * description: TOTP verified, SSH connection established. - * 400: - * description: Session ID and TOTP code required. - * 401: - * description: Invalid TOTP code. - * 404: - * description: TOTP session expired. - */ -app.post("/docker/ssh/connect-totp", async (req, res) => { - const { sessionId, totpCode } = req.body; - const userId = getRequestUserId(req); - - if (!userId) { - sshLogger.error("TOTP verification rejected: no authenticated user", { - operation: "docker_totp_auth", - sessionId, - }); - return res.status(401).json({ error: "Authentication required" }); - } - - if (!sessionId || !totpCode) { - return res.status(400).json({ error: "Session ID and TOTP code required" }); - } - - const session = pendingTOTPSessions[sessionId]; - - if (!session) { - sshLogger.warn("TOTP session not found or expired", { - operation: "docker_totp_verify", - sessionId, - userId, - availableSessions: Object.keys(pendingTOTPSessions), - }); - return res - .status(404) - .json({ error: "TOTP session expired. Please reconnect." }); - } - - if (Date.now() - session.createdAt > 180000) { - delete pendingTOTPSessions[sessionId]; - try { - session.client.end(); - } catch { - // expected - } - sshLogger.warn("TOTP session timeout before code submission", { - operation: "docker_totp_verify", - sessionId, - userId, - age: Date.now() - session.createdAt, - }); - return res - .status(408) - .json({ error: "TOTP session timeout. Please reconnect." }); - } - - const responses = (session.prompts || []).map((p, index) => { - if (index === session.totpPromptIndex) { - return totpCode; - } - if (/password/i.test(p.prompt) && session.resolvedPassword) { - return session.resolvedPassword; - } - return ""; - }); - - let responseSent = false; - - const responseTimeout = setTimeout(() => { - if (!responseSent) { - responseSent = true; - delete pendingTOTPSessions[sessionId]; - sshLogger.warn("TOTP verification timeout", { - operation: "docker_totp_verify", - sessionId, - userId, - }); - res.status(408).json({ error: "TOTP verification timeout" }); - } - }, 60000); - - session.client.once("ready", () => { - if (responseSent) return; - responseSent = true; - clearTimeout(responseTimeout); - - delete pendingTOTPSessions[sessionId]; - - setTimeout(() => { - sshSessions[sessionId] = { - client: session.client, - isConnected: true, - lastActive: Date.now(), - activeOperations: 0, - hostId: session.hostId, - userId, - containerRuntime: session.containerRuntime, - }; - scheduleSessionCleanup(sessionId); - - res.json({ - status: "success", - message: "TOTP verified, SSH connection established", - }); - - if (session.hostId && session.userId) { - (async () => { - try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, session.hostId!), - eq(hosts.userId, session.userId!), - ), - ), - "ssh_data", - session.userId!, - ); - - const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name - : `${session.username}@${session.ip}:${session.port}`; - - await axios.post( - "http://localhost:30006/activity/log", - { - type: "docker", - hostId: session.hostId, - hostName, - }, - { - headers: { - Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, - }, - }, - ); - } catch (error) { - sshLogger.warn("Failed to log Docker activity (TOTP)", { - operation: "activity_log_error", - userId: session.userId, - hostId: session.hostId, - error: error instanceof Error ? error.message : "Unknown error", - }); - } - })(); - } - }, 200); - }); - - session.client.once("error", (err) => { - if (responseSent) return; - responseSent = true; - clearTimeout(responseTimeout); - - delete pendingTOTPSessions[sessionId]; - - sshLogger.error("TOTP verification failed", { - operation: "docker_totp_verify", - sessionId, - userId, - error: err.message, - }); - - res.status(401).json({ status: "error", message: "Invalid TOTP code" }); - }); - - session.finish(responses); -}); - -/** - * @openapi - * /docker/ssh/connect-warpgate: - * post: - * summary: Complete Warpgate authentication - * description: Submits empty response to complete Warpgate authentication after user completes browser auth. - * tags: - * - Docker - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - sessionId - * properties: - * sessionId: - * type: string - * description: Session ID from initial connection attempt - * responses: - * 200: - * description: Warpgate authentication completed successfully. - * 401: - * description: Authentication failed or unauthorized. - * 404: - * description: Warpgate session expired. - */ -app.post("/docker/ssh/connect-warpgate", async (req, res) => { - const { sessionId } = req.body; - const userId = getRequestUserId(req); - - if (!userId) { - sshLogger.error("Warpgate verification rejected: no authenticated user", { - operation: "docker_warpgate_auth", - sessionId, - }); - return res.status(401).json({ error: "Authentication required" }); - } - - if (!sessionId) { - return res.status(400).json({ error: "Session ID required" }); - } - - const session = pendingTOTPSessions[sessionId]; - - if (!session) { - sshLogger.warn("Warpgate session not found or expired", { - operation: "docker_warpgate_verify", - sessionId, - userId, - availableSessions: Object.keys(pendingTOTPSessions), - }); - return res - .status(404) - .json({ error: "Warpgate session expired. Please reconnect." }); - } - - if (!session.isWarpgate) { - return res.status(400).json({ error: "Session is not a Warpgate session" }); - } - - if (Date.now() - session.createdAt > 300000) { - delete pendingTOTPSessions[sessionId]; - try { - session.client.end(); - } catch { - // expected - } - sshLogger.warn("Warpgate session timeout before completion", { - operation: "docker_warpgate_verify", - sessionId, - userId, - age: Date.now() - session.createdAt, - }); - return res - .status(408) - .json({ error: "Warpgate session timeout. Please reconnect." }); - } - - let responseSent = false; - - const responseTimeout = setTimeout(() => { - if (!responseSent) { - responseSent = true; - delete pendingTOTPSessions[sessionId]; - sshLogger.warn("Warpgate verification timeout", { - operation: "docker_warpgate_verify", - sessionId, - userId, - }); - res.status(408).json({ error: "Warpgate verification timeout" }); - } - }, 60000); - - session.client.once("ready", () => { - if (responseSent) return; - responseSent = true; - clearTimeout(responseTimeout); - - delete pendingTOTPSessions[sessionId]; - - setTimeout(() => { - sshSessions[sessionId] = { - client: session.client, - isConnected: true, - lastActive: Date.now(), - activeOperations: 0, - hostId: session.hostId, - userId, - containerRuntime: session.containerRuntime, - }; - scheduleSessionCleanup(sessionId); - - res.json({ - status: "success", - message: "Warpgate verified, SSH connection established", - }); - - if (session.hostId && session.userId) { - (async () => { - try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, session.hostId!), - eq(hosts.userId, session.userId!), - ), - ), - "ssh_data", - session.userId!, - ); - - const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name - : `${session.username}@${session.ip}:${session.port}`; - - await axios.post( - "http://localhost:30006/activity/log", - { - type: "docker", - hostId: session.hostId, - hostName, - }, - { - headers: { - Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, - }, - }, - ); - } catch (error) { - sshLogger.warn("Failed to log Docker activity (Warpgate)", { - operation: "activity_log_error", - userId: session.userId, - hostId: session.hostId, - error: error instanceof Error ? error.message : "Unknown error", - }); - } - })(); - } - }, 200); - }); - - session.client.once("error", (err) => { - if (responseSent) return; - responseSent = true; - clearTimeout(responseTimeout); - - delete pendingTOTPSessions[sessionId]; - - sshLogger.error("Warpgate verification failed", { - operation: "docker_warpgate_verify", - sessionId, - userId, - error: err.message, - }); - - res - .status(401) - .json({ status: "error", message: "Warpgate authentication failed" }); - }); - - session.finish([""]); -}); - -/** - * @openapi - * /docker/ssh/keepalive: - * post: - * summary: Keep SSH session alive - * description: Keeps an active SSH session alive. - * tags: - * - Docker - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * sessionId: - * type: string - * responses: - * 200: - * description: Session keepalive successful. - * 400: - * description: Session ID is required or session not found. - */ -app.post("/docker/ssh/keepalive", async (req, res) => { - const { sessionId } = req.body; - const userId = getRequestUserId(req); - - if (!sessionId) { - return res.status(400).json({ error: "Session ID is required" }); - } - - const session = sshSessions[sessionId]; - - if (!session || !session.isConnected) { - return res.status(400).json({ - error: "SSH session not found or not connected", - connected: false, - }); - } - - if (session.userId && session.userId !== userId) { - return res.status(403).json({ error: "Session access denied" }); - } - - session.lastActive = Date.now(); - scheduleSessionCleanup(sessionId); - - res.json({ - success: true, - connected: true, - message: "Session keepalive successful", - lastActive: session.lastActive, - }); -}); - -/** - * @openapi - * /docker/ssh/status: - * get: - * summary: Check SSH session status - * description: Checks the status of an active SSH session. - * tags: - * - Docker - * parameters: - * - in: query - * name: sessionId - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Session status. - * 400: - * description: Session ID is required. - */ -app.get("/docker/ssh/status", async (req, res) => { - const sessionId = req.query.sessionId as string; - - if (!sessionId) { - return res.status(400).json({ error: "Session ID is required" }); - } - - const isConnected = !!sshSessions[sessionId]?.isConnected; - - res.json({ success: true, connected: isConnected }); -}); - -/** - * @openapi - * /docker/validate/{sessionId}: - * get: - * summary: Validate Docker availability - * description: Validates if Docker is available on the host. - * tags: - * - Docker - * parameters: - * - in: path - * name: sessionId - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Docker availability status. - * 400: - * description: SSH session not found or not connected. - * 500: - * description: Validation failed. - */ -app.get("/docker/validate/:sessionId", async (req, res) => { - const { sessionId } = req.params; - const userId = getRequestUserId(req); - - if (!userId) { - return res.status(401).json({ error: "Authentication required" }); - } - - if (pendingTOTPSessions[sessionId]) { - return res.status(400).json({ - error: "Connection pending authentication", - code: "AUTH_PENDING", - }); - } - - const session = sshSessions[sessionId]; - - if (!session || !session.isConnected) { - return res.status(400).json({ - error: "SSH session not found or not connected", - }); - } - - session.lastActive = Date.now(); - session.activeOperations++; - - try { - try { - const runtime = session.containerRuntime ?? "docker"; - const runtimeLabel = getRuntimeLabel(runtime); - const versionOutput = await executeDockerCommand( - session, - containerCommand(runtime, "--version"), - sessionId, - userId, - session.hostId, - ); - const versionMatch = versionOutput.match( - /(?:Docker|podman) version ([^\s,]+)/i, - ); - const version = versionMatch ? versionMatch[1] : "unknown"; - - try { - await executeDockerCommand( - session, - containerCommand(runtime, "ps"), - sessionId, - userId, - session.hostId, - ); - - session.activeOperations--; - return res.json({ - available: true, - version, - runtime, - }); - } catch (daemonError) { - session.activeOperations--; - const errorMsg = - daemonError instanceof Error ? daemonError.message : ""; - - if (errorMsg.includes("Cannot connect to the Docker daemon")) { - return res.json({ - available: false, - error: `${runtimeLabel} daemon is not running or accessible`, - code: "DAEMON_NOT_RUNNING", - runtime, - }); - } - - if (errorMsg.includes("permission denied")) { - return res.json({ - available: false, - error: `Permission denied accessing ${runtimeLabel}`, - code: "PERMISSION_DENIED", - runtime, - }); - } - - return res.json({ - available: false, - error: errorMsg, - code: "DOCKER_ERROR", - runtime, - }); - } - } catch { - session.activeOperations--; - const runtime = session.containerRuntime ?? "docker"; - const runtimeLabel = getRuntimeLabel(runtime); - return res.json({ - available: false, - error: `${runtimeLabel} is not installed on this host.`, - code: "NOT_INSTALLED", - runtime, - }); - } - } catch (error) { - session.activeOperations--; - sshLogger.error("Docker validation error", error, { - operation: "docker_validate", - sessionId, - userId, - }); - - res.status(500).json({ - available: false, - error: error instanceof Error ? error.message : "Validation failed", - }); - } -}); - -registerDockerContainerRoutes(app, { - sshSessions, - pendingTOTPSessions, - getRequestUserId, - executeDockerCommand, - dockerTimestampPattern: DOCKER_TIMESTAMP_RE, -}); - -const PORT = 30007; - -app.listen(PORT, async () => { - try { - await authManager.initialize(); - } catch (err) { - sshLogger.error("Failed to initialize Docker backend", err, { - operation: "startup", - }); - } -}); - -process.on("SIGINT", () => { - Object.keys(sshSessions).forEach((sessionId) => { - cleanupSession(sessionId); - }); - process.exit(0); -}); - -process.on("SIGTERM", () => { - Object.keys(sshSessions).forEach((sessionId) => { - cleanupSession(sessionId); - }); - process.exit(0); -}); diff --git a/src/backend/ssh/host-metrics-helpers.ts b/src/backend/ssh/host-metrics-helpers.ts deleted file mode 100644 index e9977413..00000000 --- a/src/backend/ssh/host-metrics-helpers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; - -export type StatsCapableHost = { - connectionType?: string; - authType?: string; -}; - -export type TcpPingStatsConfig = { - statusCheckEnabled: boolean; - disableTcpPing?: boolean; -}; - -export function supportsMetrics(host: StatsCapableHost): boolean { - const connectionType = host.connectionType || "ssh"; - if (connectionType !== "ssh") return false; - if (host.authType === "none" || host.authType === "opkssh") return false; - return true; -} - -export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean { - return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing; -} - -export function createConnectionLog( - type: "info" | "success" | "warning" | "error", - stage: ConnectionStage, - message: string, - details?: Record, -): Omit { - return { - type, - stage, - message, - details, - }; -} diff --git a/src/backend/ssh/host-metrics-jump-hosts.ts b/src/backend/ssh/host-metrics-jump-hosts.ts deleted file mode 100644 index a54403b8..00000000 --- a/src/backend/ssh/host-metrics-jump-hosts.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { Client } from "ssh2"; -import { and, eq } from "drizzle-orm"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { statsLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; - -interface JumpHostConfig { - id: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - credentialId?: number; - useSocks5?: boolean | null; - socks5Host?: string | null; - socks5Port?: number | null; - socks5Username?: string | null; - socks5Password?: string | null; - socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; - [key: string]: unknown; -} - -async function resolveJumpHost( - hostId: number, - userId: string, -): Promise { - try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); - - if (hostResults.length === 0) { - return null; - } - - const host = hostResults[0]; - const ownerId = (host.userId || userId) as string; - - if (host.credentialId) { - if (userId !== ownerId) { - try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { - return { - ...host, - password: sharedCred.password, - key: sharedCred.key, - keyPassword: sharedCred.keyPassword, - keyType: sharedCred.keyType, - authType: sharedCred.key - ? "key" - : sharedCred.password - ? "password" - : "none", - } as JumpHostConfig; - } - } catch { - // fall through to owner credential lookup - } - } - - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", - ownerId, - ); - - if (credentials.length > 0) { - const credential = credentials[0]; - return { - ...host, - password: credential.password as string | undefined, - key: (credential.key || credential.privateKey) as string | undefined, - keyPassword: credential.keyPassword as string | undefined, - keyType: credential.keyType as string | undefined, - authType: credential.authType as string | undefined, - } as JumpHostConfig; - } - } - - return host as JumpHostConfig; - } catch (error) { - statsLogger.error("Failed to resolve jump host", error, { - operation: "resolve_jump_host", - hostId, - userId, - }); - return null; - } -} - -export async function createJumpHostChain( - jumpHosts: Array<{ hostId: number }>, - userId: string, - socks5Config?: SOCKS5Config | null, -): Promise { - if (!jumpHosts || jumpHosts.length === 0) { - return null; - } - - let currentClient: Client | null = null; - const clients: Client[] = []; - - try { - const jumpHostConfigs: Array>> = - []; - for (let i = 0; i < jumpHosts.length; i++) { - const config = await resolveJumpHost(jumpHosts[i].hostId, userId); - jumpHostConfigs.push(config); - } - - const totalHops = jumpHostConfigs.length; - - for (let i = 0; i < jumpHostConfigs.length; i++) { - if (!jumpHostConfigs[i]) { - statsLogger.error(`Jump host ${i + 1} not found`, undefined, { - operation: "jump_host_chain", - hostId: jumpHosts[i].hostId, - hopIndex: i, - totalHops, - }); - clients.forEach((c) => c.end()); - return null; - } - } - - const firstHopSocks5Config = getJumpHostSocks5Config( - jumpHostConfigs[0], - socks5Config, - ); - let proxySocket: import("net").Socket | null = null; - if (firstHopSocks5Config?.useSocks5) { - const firstHop = jumpHostConfigs[0]!; - proxySocket = await createSocks5Connection( - firstHop.ip, - firstHop.port || 22, - firstHopSocks5Config, - ); - } - - for (let i = 0; i < jumpHostConfigs.length; i++) { - const jumpHostConfig = jumpHostConfigs[i]!; - - const jumpClient = new Client(); - clients.push(jumpClient); - - const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( - jumpHostConfig.id, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - null, - userId, - true, - ); - - // eslint-disable-next-line no-async-promise-executor - const connected = await new Promise(async (resolve) => { - const timeout = setTimeout(() => { - resolve(false); - }, 30000); - - jumpClient.on("ready", () => { - clearTimeout(timeout); - resolve(true); - }); - - jumpClient.on("error", (err) => { - clearTimeout(timeout); - statsLogger.error( - `Jump host ${i + 1}/${totalHops} connection failed`, - err, - { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - hopIndex: i, - totalHops, - previousHop: - i > 0 - ? jumpHostConfigs[i - 1]?.ip - : proxySocket - ? "proxy" - : "direct", - usedProxySocket: i === 0 && !!proxySocket, - }, - ); - resolve(false); - }); - - const connectConfig: Record = { - host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, - port: jumpHostConfig.port || 22, - username: jumpHostConfig.username, - tryKeyboard: jumpHostConfig.authType !== "none", - readyTimeout: 60000, - hostVerifier: jumpHostVerifier, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group18-sha512", - "diffie-hellman-group17-sha512", - "diffie-hellman-group16-sha512", - "diffie-hellman-group15-sha512", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { - connectConfig.password = jumpHostConfig.password; - } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { - const cleanKey = jumpHostConfig.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); - if (jumpHostConfig.keyPassword) { - connectConfig.passphrase = jumpHostConfig.keyPassword; - } - } else if (jumpHostConfig.authType === "agent") { - const result = await applyAgentAuth( - connectConfig, - jumpHostConfig.terminalConfig as - | Record - | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } - } - - jumpClient.on( - "keyboard-interactive", - ( - _name: string, - _instructions: string, - _lang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && jumpHostConfig.password) { - return jumpHostConfig.password as string; - } - return ""; - }); - finish(responses); - }, - ); - - if (currentClient) { - currentClient.forwardOut( - "127.0.0.1", - 0, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - (err, stream) => { - if (err) { - clearTimeout(timeout); - resolve(false); - return; - } - connectConfig.sock = stream; - jumpClient.connect(connectConfig); - }, - ); - } else if (proxySocket) { - connectConfig.sock = proxySocket; - jumpClient.connect(connectConfig); - } else { - jumpClient.connect(connectConfig); - } - }); - - if (!connected) { - clients.forEach((c) => c.end()); - return null; - } - - currentClient = jumpClient; - } - - return currentClient; - } catch (error) { - statsLogger.error("Failed to create jump host chain", error, { - operation: "jump_host_chain", - }); - clients.forEach((c) => c.end()); - return null; - } -} diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts deleted file mode 100644 index 4759fc9b..00000000 --- a/src/backend/ssh/host-resolver.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials, vaultProfiles } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { logger } from "../utils/logger.js"; -import { - pickResolvedUsername, - expandOidcUsername, -} from "./credential-username.js"; -import type { SSHHost } from "../../types/index.js"; - -const sshLogger = logger; - -/** - * Resolve a host with its credentials server-side by hostId. - * This avoids passing credentials through the frontend. - */ -export async function resolveHostById( - hostId: number, - userId: string, -): Promise { - const db = getDb(); - - const hostResults = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); - - if (hostResults.length === 0) return null; - - const host = hostResults[0] as Record; - - // Parse JSON fields - if (typeof host.jumpHosts === "string" && host.jumpHosts) { - try { - host.jumpHosts = JSON.parse(host.jumpHosts as string); - } catch { - host.jumpHosts = []; - } - } - if (typeof host.tunnelConnections === "string") { - try { - host.tunnelConnections = JSON.parse(host.tunnelConnections as string); - } catch { - host.tunnelConnections = []; - } - } - if (typeof host.statsConfig === "string" && host.statsConfig) { - try { - host.statsConfig = JSON.parse(host.statsConfig as string); - } catch { - host.statsConfig = undefined; - } - } - if (typeof host.terminalConfig === "string" && host.terminalConfig) { - try { - host.terminalConfig = JSON.parse(host.terminalConfig as string); - } catch { - host.terminalConfig = undefined; - } - } - if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) { - try { - host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string); - } catch { - host.socks5ProxyChain = []; - } - } - if (typeof host.quickActions === "string" && host.quickActions) { - try { - host.quickActions = JSON.parse(host.quickActions as string); - } catch { - host.quickActions = []; - } - } - - // Resolve credential if using credential-based auth - if (host.credentialId) { - const ownerId = (host.userId || userId) as string; - try { - // Try user's own override credential first - if (userId !== ownerId) { - try { - const { hostAccess } = await import("../database/db/schema.js"); - const accessRecords = await db - .select() - .from(hostAccess) - .where( - and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), - ) - .limit(1); - const overrideCredId = accessRecords[0]?.overrideCredentialId as - | number - | null; - if (overrideCredId) { - const userCreds = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, overrideCredId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); - if (userCreds.length > 0) { - const cred = userCreds[0] as Record; - host.password = cred.password; - host.key = cred.key; - host.keyPassword = cred.keyPassword; - host.keyType = cred.keyType; - host.username = pickResolvedUsername( - host.username, - cred.username, - host.overrideCredentialUsername, - ); - host.authType = cred.key - ? "key" - : cred.password - ? "password" - : "none"; - host.username = await expandOidcUsername( - host.username as string | undefined, - userId, - ); - return host as unknown as SSHHost; - } - } - } catch { - // fall through to shared credential - } - - try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { - host.password = sharedCred.password; - host.key = sharedCred.key; - host.keyPassword = sharedCred.keyPassword; - host.keyType = sharedCred.keyType; - host.username = pickResolvedUsername( - host.username, - sharedCred.username, - host.overrideCredentialUsername, - ); - host.authType = sharedCred.key - ? "key" - : sharedCred.password - ? "password" - : "none"; - host.username = await expandOidcUsername( - host.username as string | undefined, - userId, - ); - return host as unknown as SSHHost; - } - } catch (e) { - sshLogger.warn("Failed to get shared credential", { - operation: "host_resolver_shared_credential", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); - } - - return null; - } - - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", - ownerId, - ); - - if (credentials.length > 0) { - const cred = credentials[0] as Record; - host.password = cred.password; - // Prefer the normalised private key; fall back to raw key field - host.key = (cred.privateKey || cred.key) as string | null; - host.keyPassword = cred.keyPassword; - host.keyType = cred.keyType; - // CA-signed certificate for cert-based auth - (host as Record).certPublicKey = - cred.certPublicKey || null; - host.username = pickResolvedUsername( - host.username, - cred.username, - host.overrideCredentialUsername, - ); - host.authType = host.key ? "key" : host.password ? "password" : "none"; - } - } catch (e) { - sshLogger.warn("Failed to resolve credential for host", { - operation: "host_resolver_credential", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); - } - } - - host.username = await expandOidcUsername( - host.username as string | undefined, - userId, - ); - - // Resolve a Vault SSH signer profile (shared settings, no secrets). The - // certificate itself is obtained per-user at connect time via Vault OIDC. - if (host.vaultProfileId) { - try { - const profiles = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, host.vaultProfileId as number)) - .limit(1); - if (profiles.length > 0) { - (host as Record).vaultProfile = profiles[0]; - host.authType = "vault"; - } - } catch (e) { - sshLogger.warn("Failed to resolve vault profile for host", { - operation: "host_resolver_vault_profile", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); - } - } - - return host as unknown as SSHHost; -} - -/** - * Check if a user has access to a host (owner or shared access). - */ -export async function checkHostAccess( - hostId: number, - userId: string, - hostUserId: string, - requiredPermission: "read" | "execute" = "execute", -): Promise { - if (userId === hostUserId) return true; - - try { - const { PermissionManager } = - await import("../utils/permission-manager.js"); - const permissionManager = PermissionManager.getInstance(); - const accessInfo = await permissionManager.canAccessHost( - userId, - hostId, - requiredPermission, - ); - return accessInfo.hasAccess; - } catch { - return false; - } -} diff --git a/src/backend/ssh/ssh-connection-pool.ts b/src/backend/ssh/ssh-connection-pool.ts deleted file mode 100644 index b521842b..00000000 --- a/src/backend/ssh/ssh-connection-pool.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Client } from "ssh2"; -import { sshLogger } from "../utils/logger.js"; - -interface PooledConnection { - client: Client; - lastUsed: number; - inUse: boolean; - hostKey: string; -} - -class SSHConnectionPool { - private connections = new Map(); - private maxConnectionsPerHost = 3; - private cleanupInterval: NodeJS.Timeout; - - constructor() { - this.cleanupInterval = setInterval( - () => { - this.cleanup(); - }, - 2 * 60 * 1000, - ); - } - - private isConnectionHealthy(client: Client): boolean { - try { - const sock = ( - client as unknown as { - _sock?: { destroyed?: boolean; writable?: boolean }; - } - )._sock; - if (sock && (sock.destroyed || !sock.writable)) { - return false; - } - return true; - } catch { - return false; - } - } - - async getConnection( - key: string, - factory: () => Promise, - ): Promise { - let connections = this.connections.get(key) || []; - - const available = connections.find((conn) => !conn.inUse); - if (available) { - if (!this.isConnectionHealthy(available.client)) { - sshLogger.warn("Removing unhealthy connection from pool", { - operation: "pool_remove_dead", - hostKey: key, - }); - try { - available.client.end(); - } catch { - // expected - } - connections = connections.filter((c) => c !== available); - this.connections.set(key, connections); - } else { - available.inUse = true; - available.lastUsed = Date.now(); - return available.client; - } - } - - if (connections.length < this.maxConnectionsPerHost) { - const client = await factory(); - const pooled: PooledConnection = { - client, - lastUsed: Date.now(), - inUse: true, - hostKey: key, - }; - connections.push(pooled); - this.connections.set(key, connections); - - client.on("end", () => { - this.removeConnection(key, client); - }); - client.on("close", () => { - this.removeConnection(key, client); - }); - - return client; - } - - return new Promise((resolve) => { - const checkAvailable = () => { - const conns = this.connections.get(key) || []; - const avail = conns.find((conn) => !conn.inUse); - if (avail) { - if (!this.isConnectionHealthy(avail.client)) { - try { - avail.client.end(); - } catch { - // expected - } - const filtered = conns.filter((c) => c !== avail); - this.connections.set(key, filtered); - factory().then((client) => { - const pooled: PooledConnection = { - client, - lastUsed: Date.now(), - inUse: true, - hostKey: key, - }; - filtered.push(pooled); - this.connections.set(key, filtered); - client.on("end", () => this.removeConnection(key, client)); - client.on("close", () => this.removeConnection(key, client)); - resolve(client); - }); - } else { - avail.inUse = true; - avail.lastUsed = Date.now(); - resolve(avail.client); - } - } else { - setTimeout(checkAvailable, 100); - } - }; - checkAvailable(); - }); - } - - releaseConnection(key: string, client: Client): void { - const connections = this.connections.get(key) || []; - const pooled = connections.find((conn) => conn.client === client); - if (pooled) { - pooled.inUse = false; - pooled.lastUsed = Date.now(); - } - } - - private removeConnection(key: string, client: Client): void { - const connections = this.connections.get(key); - if (!connections) return; - const filtered = connections.filter((c) => c.client !== client); - if (filtered.length === 0) { - this.connections.delete(key); - } else { - this.connections.set(key, filtered); - } - } - - clearKeyConnections(key: string): void { - const connections = this.connections.get(key) || []; - for (const conn of connections) { - try { - conn.client.end(); - } catch { - // expected - } - } - this.connections.delete(key); - } - - private cleanup(): void { - const now = Date.now(); - const maxAge = 10 * 60 * 1000; - - for (const [hostKey, connections] of this.connections.entries()) { - const activeConnections = connections.filter((conn) => { - if (!conn.inUse && now - conn.lastUsed > maxAge) { - try { - conn.client.end(); - } catch { - // expected - } - return false; - } - if (!this.isConnectionHealthy(conn.client)) { - try { - conn.client.end(); - } catch { - // expected - } - return false; - } - return true; - }); - - if (activeConnections.length === 0) { - this.connections.delete(hostKey); - } else { - this.connections.set(hostKey, activeConnections); - } - } - } - - clearAllConnections(): void { - for (const connections of this.connections.values()) { - for (const conn of connections) { - try { - conn.client.end(); - } catch { - // expected - } - } - } - this.connections.clear(); - } - - destroy(): void { - clearInterval(this.cleanupInterval); - this.clearAllConnections(); - } -} - -export const connectionPool = new SSHConnectionPool(); - -export async function withConnection( - key: string, - factory: () => Promise, - fn: (client: Client) => Promise, -): Promise { - const client = await connectionPool.getConnection(key, factory); - try { - return await fn(client); - } finally { - connectionPool.releaseConnection(key, client); - } -} diff --git a/src/backend/ssh/terminal-jump-hosts.ts b/src/backend/ssh/terminal-jump-hosts.ts deleted file mode 100644 index 2f99e3a6..00000000 --- a/src/backend/ssh/terminal-jump-hosts.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import ssh2Pkg, { type Client as SSHClientType } from "ssh2"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { sshLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; - -const { Client } = ssh2Pkg; - -interface JumpHostConfig { - id: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - credentialId?: number; - useSocks5?: boolean | null; - socks5Host?: string | null; - socks5Port?: number | null; - socks5Username?: string | null; - socks5Password?: string | null; - socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; - [key: string]: unknown; -} - -async function resolveJumpHost( - hostId: number, - userId: string, -): Promise { - sshLogger.info("Resolving jump host", { - operation: "terminal_jumphost_resolve", - userId, - hostId, - }); - try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); - - if (hostResults.length === 0) { - return null; - } - - const host = hostResults[0]; - const ownerId = (host.userId || userId) as string; - - if (host.credentialId) { - if (userId !== ownerId) { - try { - const { SharedCredentialManager } = - await import("../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - const sharedCred = await sharedCredManager.getSharedCredentialForUser( - hostId, - userId, - ); - if (sharedCred) { - return { - ...host, - password: sharedCred.password, - key: sharedCred.key, - keyPassword: sharedCred.keyPassword, - keyType: sharedCred.keyType, - authType: sharedCred.key - ? "key" - : sharedCred.password - ? "password" - : "none", - } as JumpHostConfig; - } - } catch { - // fall through to owner credential lookup - } - } - - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", - ownerId, - ); - - if (credentials.length > 0) { - const credential = credentials[0]; - return { - ...host, - password: credential.password as string | undefined, - key: (credential.key || credential.privateKey) as string | undefined, - keyPassword: credential.keyPassword as string | undefined, - keyType: credential.keyType as string | undefined, - authType: credential.authType as string | undefined, - } as JumpHostConfig; - } - } - - return host as JumpHostConfig; - } catch (error) { - sshLogger.error("Failed to resolve jump host", error, { - operation: "resolve_jump_host", - hostId, - userId, - }); - return null; - } -} - -export async function createJumpHostChain( - jumpHosts: Array<{ hostId: number }>, - userId: string, - socks5Config?: SOCKS5Config | null, -): Promise { - if (!jumpHosts || jumpHosts.length === 0) { - return null; - } - - let currentClient: SSHClientType | null = null; - const clients: SSHClientType[] = []; - - try { - const jumpHostConfigs = await Promise.all( - jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), - ); - - const totalHops = jumpHostConfigs.length; - - for (let i = 0; i < jumpHostConfigs.length; i++) { - if (!jumpHostConfigs[i]) { - sshLogger.error(`Jump host ${i + 1} not found`, undefined, { - operation: "jump_host_chain", - hostId: jumpHosts[i].hostId, - hopIndex: i, - totalHops, - }); - clients.forEach((c) => c.end()); - return null; - } - } - - const firstHopSocks5Config = getJumpHostSocks5Config( - jumpHostConfigs[0], - socks5Config, - ); - let proxySocket: import("net").Socket | null = null; - if (firstHopSocks5Config?.useSocks5) { - const firstHop = jumpHostConfigs[0]; - proxySocket = await createSocks5Connection( - firstHop.ip, - firstHop.port || 22, - firstHopSocks5Config, - ); - } - - for (let i = 0; i < jumpHostConfigs.length; i++) { - const jumpHostConfig = jumpHostConfigs[i]; - - const jumpClient = new Client(); - clients.push(jumpClient); - - const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( - jumpHostConfig.id, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - null, - userId, - true, - ); - - // eslint-disable-next-line no-async-promise-executor - const connected = await new Promise(async (resolve) => { - const timeout = setTimeout(() => { - resolve(false); - }, 30000); - - jumpClient.on("ready", () => { - clearTimeout(timeout); - sshLogger.success("Jump host connection established", { - operation: "terminal_jumphost_connected", - userId, - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - depth: i, - hopIndex: i, - totalHops, - usedProxySocket: i === 0 && !!proxySocket, - }); - resolve(true); - }); - - jumpClient.on("error", (err) => { - clearTimeout(timeout); - sshLogger.error( - `Jump host ${i + 1}/${totalHops} connection failed`, - err, - { - operation: "jump_host_connect", - hostId: jumpHostConfig.id, - ip: jumpHostConfig.ip, - hopIndex: i, - totalHops, - previousHop: - i > 0 - ? jumpHostConfigs[i - 1]?.ip - : proxySocket - ? "proxy" - : "direct", - usedProxySocket: i === 0 && !!proxySocket, - }, - ); - resolve(false); - }); - - const connectConfig: Record = { - host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, - port: jumpHostConfig.port || 22, - username: jumpHostConfig.username, - tryKeyboard: jumpHostConfig.authType !== "none", - readyTimeout: 60000, - hostVerifier: jumpHostVerifier, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group18-sha512", - "diffie-hellman-group17-sha512", - "diffie-hellman-group16-sha512", - "diffie-hellman-group15-sha512", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { - connectConfig.password = jumpHostConfig.password; - } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { - const cleanKey = jumpHostConfig.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); - if (jumpHostConfig.keyPassword) { - connectConfig.passphrase = jumpHostConfig.keyPassword; - } - } else if (jumpHostConfig.authType === "agent") { - const result = await applyAgentAuth( - connectConfig, - jumpHostConfig.terminalConfig as - | Record - | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } - } - - jumpClient.on( - "keyboard-interactive", - ( - _name: string, - _instructions: string, - _lang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && jumpHostConfig.password) { - return jumpHostConfig.password as string; - } - return ""; - }); - finish(responses); - }, - ); - - if (currentClient) { - currentClient.forwardOut( - "127.0.0.1", - 0, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - (err, stream) => { - if (err) { - clearTimeout(timeout); - resolve(false); - return; - } - connectConfig.sock = stream; - jumpClient.connect(connectConfig); - }, - ); - } else if (proxySocket) { - connectConfig.sock = proxySocket; - jumpClient.connect(connectConfig); - } else { - jumpClient.connect(connectConfig); - } - }); - - if (!connected) { - clients.forEach((c) => c.end()); - return null; - } - - currentClient = jumpClient; - } - - return currentClient; - } catch (error) { - sshLogger.error("Failed to create jump host chain", error, { - operation: "jump_host_chain", - }); - clients.forEach((c) => c.end()); - return null; - } -} diff --git a/src/backend/ssh/tmux-helper.test.ts b/src/backend/ssh/tmux-helper.test.ts deleted file mode 100644 index 04e97dce..00000000 --- a/src/backend/ssh/tmux-helper.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { tmuxCommand, withTmuxPath } from "./tmux-helper.js"; - -describe("tmux command path handling", () => { - it("adds common non-login shell tmux paths", () => { - const command = withTmuxPath("command -v tmux"); - - expect(command).toContain("/opt/homebrew/bin"); - expect(command).toContain("/usr/local/bin"); - expect(command).toContain("/opt/bin"); - expect(command).toContain("/usr/pkg/bin"); - expect(command).toContain(":$PATH; command -v tmux"); - }); - - it("wraps tmux invocations with the same path", () => { - expect(tmuxCommand("list-sessions")).toMatch( - /^PATH=.*:\$PATH; tmux list-sessions$/, - ); - }); -}); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index a9b57fdc..dfc0a8f9 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from "url"; import { AutoSSLSetup } from "./utils/auto-ssl-setup.js"; import { AuthManager } from "./utils/auth-manager.js"; import { DataCrypto } from "./utils/data-crypto.js"; +import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js"; +import { DatabaseSaveTrigger } from "./utils/database-save-trigger.js"; import { SystemCrypto } from "./utils/system-crypto.js"; import { systemLogger, @@ -36,58 +38,27 @@ import { port: process.env.PORT || 4090, }); - let version = "unknown"; - - const versionSources = [ - () => process.env.VERSION, - () => { + let version = process.env.VERSION || "unknown"; + if (version === "unknown") { + const candidates = [ + path.join(process.cwd(), "package.json"), + path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../../package.json", + ), + ]; + for (const packageJsonPath of candidates) { try { - const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse( readFileSync(packageJsonPath, "utf-8"), ); - return packageJson.version; + if (packageJson.version) { + version = packageJson.version; + break; + } } catch { - return null; + // try the next location } - }, - () => { - try { - const __filename = fileURLToPath(import.meta.url); - const packageJsonPath = path.join( - path.dirname(__filename), - "../../../package.json", - ); - const packageJson = JSON.parse( - readFileSync(packageJsonPath, "utf-8"), - ); - return packageJson.version; - } catch { - return null; - } - }, - () => { - try { - const packageJsonPath = path.join("/app", "package.json"); - const packageJson = JSON.parse( - readFileSync(packageJsonPath, "utf-8"), - ); - return packageJson.version; - } catch { - return null; - } - }, - ]; - - for (const getVersion of versionSources) { - try { - const foundVersion = getVersion(); - if (foundVersion && foundVersion !== "unknown") { - version = foundVersion; - break; - } - } catch { - continue; } } versionLogger.info(`Termix Backend starting - Version: ${version}`, { @@ -101,6 +72,8 @@ import { await systemCrypto.initializeEncryptionKey(); await systemCrypto.initializeInternalAuthToken(); + ensureDatabaseLayerPreupgradeBackup({ dataDir, version }); + await AutoSSLSetup.initialize(); systemLogger.success("SSL setup completed", { operation: "backend_init_ssl", @@ -113,10 +86,25 @@ import { operation: "backend_init_db", }); + const { UserKeyManager } = await import("./utils/user-keys.js"); + await UserKeyManager.getInstance().initialize(); + + const { runBootDekMigration } = + await import("./utils/crypto-migration/dek-migration.js"); + await runBootDekMigration({ cleanupLegacy: true }); + + const { runLegacySharedCredentialCleanup } = + await import("./utils/crypto-migration/legacy-share-cleanup.js"); + await runLegacySharedCredentialCleanup(); + const authManager = AuthManager.getInstance(); await authManager.initialize(); DataCrypto.initialize(); + const { runSharedHostSecretsMigration } = + await import("./utils/crypto-migration/shared-host-secrets-migration.js"); + await runSharedHostSecretsMigration(); + import("./utils/opkssh-binary-manager.js").then( ({ OPKSSHBinaryManager }) => { OPKSSHBinaryManager.ensureBinary().catch((error) => { @@ -137,44 +125,35 @@ import { }, ); - const dbServer = await import("./database/database.js"); - await (dbServer as unknown as { serverReady: Promise }).serverReady; - await import("./ssh/terminal.js"); - await import("./ssh/tunnel.js"); - await import("./ssh/file-manager.js"); - await import("./ssh/host-metrics.js"); - await import("./ssh/docker.js"); - await import("./ssh/docker-console.js"); - await import("./ssh/tmux-monitor.js"); // --- tmux-monitor --- - await import("./serial/serial.js"); - await import("./dashboard.js"); - await import("./homepage.js"); + const { serverReady } = await import("./database/database.js"); + await serverReady; + await import("./hosts/terminal/index.js"); + await import("./hosts/tunnel/index.js"); + await import("./hosts/file-manager/index.js"); + await import("./hosts/metrics/index.js"); + await import("./hosts/docker/index.js"); + await import("./hosts/docker/console.js"); + await import("./hosts/tmux/index.js"); + await import("./hosts/serial.js"); + await import("./services/dashboard.js"); + await import("./services/homepage.js"); // Initialize log level from database settings - const { getDb: getDbForSettings } = await import("./database/db/index.js"); - const settingsDb = getDbForSettings(); - const logLevelRow = settingsDb.$client - .prepare("SELECT value FROM settings WHERE key = 'log_level'") - .get() as { value: string } | undefined; - if (logLevelRow) { - setGlobalLogLevel(logLevelRow.value); - systemLogger.info(`Log level set to: ${logLevelRow.value}`, { + const { getCurrentSettingValue } = + await import("./database/repositories/factory.js"); + const logLevel = getCurrentSettingValue("log_level"); + if (logLevel) { + setGlobalLogLevel(logLevel); + systemLogger.info(`Log level set to: ${logLevel}`, { operation: "log_level_init", }); } // Initialize Guacamole server for RDP/VNC/Telnet support - const { getDb: getDbForGuac } = await import("./database/db/index.js"); - const guacDb = getDbForGuac(); - const guacEnabledRow = guacDb.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get() as { value: string } | undefined; - const guacEnabled = guacEnabledRow - ? guacEnabledRow.value !== "false" - : true; + const guacEnabled = getCurrentSettingValue("guac_enabled") !== "false"; if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) { - import("./guacamole/guacamole-server.js") + import("./hosts/guacamole/guacamole-server.js") .then(() => { systemLogger.info("Guacamole server initialized", { operation: "guac_init", @@ -203,9 +182,7 @@ import { operation: "shutdown", }); try { - const { saveMemoryDatabaseToFile } = - await import("./database/db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("shutdown_explicit_save"); systemLogger.info("Database saved to disk before exit", { operation: "shutdown_db_saved", }); diff --git a/src/backend/tests/database/repositories/alert-repository.test.ts b/src/backend/tests/database/repositories/alert-repository.test.ts new file mode 100644 index 00000000..e5fefb81 --- /dev/null +++ b/src/backend/tests/database/repositories/alert-repository.test.ts @@ -0,0 +1,379 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { AlertRepository } from "../../../database/repositories/alert-repository.js"; + +describe("AlertRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL + ); + + CREATE TABLE alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + trigger_type TEXT NOT NULL, + threshold_value REAL, + threshold_duration_seconds INTEGER, + cooldown_minutes INTEGER NOT NULL DEFAULT 15, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE notification_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + config TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE alert_rule_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL + ); + + CREATE TABLE alert_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + rule_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + host_name TEXT NOT NULL, + fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TEXT, + value REAL, + message TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + acknowledged INTEGER NOT NULL DEFAULT 0 + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip) + VALUES (1, 'user-1', 'alpha', '127.0.0.1'); + `); + + return new AlertRepository(context, onWrite); + } + + it("manages notification channels", async () => { + let writes = 0; + const repo = await createRepository(() => { + writes += 1; + }); + + const created = await repo.createNotificationChannel({ + userId: "user-1", + name: "Ops", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + + expect(created).toMatchObject({ + user_id: "user-1", + name: "Ops", + type: "webhook", + enabled: 1, + }); + + const updated = await repo.updateNotificationChannel(created.id, "user-1", { + name: "Ops disabled", + enabled: false, + }); + expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 }); + + expect(await repo.listNotificationChannels("user-1")).toHaveLength(1); + expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe( + false, + ); + expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe( + true, + ); + expect(await repo.listNotificationChannels("user-1")).toHaveLength(0); + expect(writes).toBe(3); + }); + + it("manages alert rules and linked channels", async () => { + const repo = await createRepository(); + const ownedChannel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Owned", + type: "ntfy", + config: '{"url":"https://ntfy.test","topic":"termix"}', + enabled: true, + }); + const foreignChannel = await repo.createNotificationChannel({ + userId: "user-2", + name: "Foreign", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + + const created = await repo.createAlertRule({ + userId: "user-1", + hostId: null, + name: "CPU high", + enabled: true, + triggerType: "cpu_threshold", + thresholdValue: 80, + thresholdDurationSeconds: 30, + cooldownMinutes: 5, + channels: [ownedChannel.id, foreignChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + + expect(created).toMatchObject({ + user_id: "user-1", + host_id: null, + name: "CPU high", + trigger_type: "cpu_threshold", + threshold_value: 80, + channels: [ownedChannel.id], + }); + + const updated = await repo.updateAlertRule(created.id, "user-1", { + name: "CPU very high", + hostId: 1, + channels: [], + now: "2026-01-02T00:00:00.000Z", + }); + expect(updated).toMatchObject({ + name: "CPU very high", + host_id: 1, + channels: [], + updated_at: "2026-01-02T00:00:00.000Z", + }); + + const rules = await repo.listAlertRules("user-1"); + expect(rules).toHaveLength(1); + expect(rules[0].channels).toEqual([]); + expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false); + expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true); + }); + + it("lists, acknowledges, and prunes firings", async () => { + const repo = await createRepository(); + const rule = await repo.createAlertRule({ + userId: "user-1", + hostId: 1, + name: "Host offline", + enabled: true, + triggerType: "host_offline", + thresholdValue: null, + thresholdDurationSeconds: null, + cooldownMinutes: 15, + channels: [], + now: "2026-01-01T00:00:00.000Z", + }); + + await repo.createFiring({ + userId: "user-1", + ruleId: rule.id, + hostId: 1, + hostName: "alpha", + value: null, + message: "down", + severity: "critical", + }); + + const listed = await repo.listAlertFirings({ + userId: "user-1", + limit: 10, + offset: 0, + }); + expect(listed.total).toBe(1); + expect(listed.firings[0]).toMatchObject({ + rule_id: rule.id, + host_name: "alpha", + acknowledged: 0, + rule_name: "Host offline", + }); + + await repo.acknowledgeFiring(listed.firings[0].id, "user-1"); + const unacknowledged = await repo.listAlertFirings({ + userId: "user-1", + acknowledged: false, + limit: 10, + offset: 0, + }); + expect(unacknowledged.total).toBe(0); + + await repo.acknowledgeAllFirings("user-1"); + repo.pruneFiringsOlderThan("user-1", 0); + }); + + it("loads enabled rules and notification channels for the alert engine", async () => { + const repo = await createRepository(); + const channel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Ops", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + const disabledChannel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Disabled", + type: "webhook", + config: '{"url":"https://disabled.test"}', + enabled: false, + }); + const rule = await repo.createAlertRule({ + userId: "user-1", + hostId: null, + name: "CPU high", + enabled: true, + triggerType: "cpu_threshold", + thresholdValue: 90, + thresholdDurationSeconds: 0, + cooldownMinutes: 15, + channels: [channel.id, disabledChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + + expect(await repo.listEnabledRulesForHost(1)).toMatchObject([ + { + id: rule.id, + userId: "user-1", + triggerType: "cpu_threshold", + enabled: true, + }, + ]); + expect(await repo.findRuleById(rule.id)).toMatchObject({ + id: rule.id, + cooldownMinutes: 15, + }); + expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([ + { + id: channel.id, + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }, + ]); + }); + + it("loads host display names for alert payloads", async () => { + const repo = await createRepository(); + + expect(await repo.getHostDisplayName(1)).toBe("alpha"); + expect(await repo.getHostDisplayName(999)).toBeNull(); + }); + + it("deletes all alert data for a user", async () => { + let writes = 0; + const repo = await createRepository(() => { + writes += 1; + }); + + const userChannel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Ops", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + const otherChannel = await repo.createNotificationChannel({ + userId: "user-2", + name: "Other", + type: "webhook", + config: '{"url":"https://other.test"}', + enabled: true, + }); + const userRule = await repo.createAlertRule({ + userId: "user-1", + hostId: 1, + name: "CPU high", + enabled: true, + triggerType: "cpu_threshold", + thresholdValue: 80, + thresholdDurationSeconds: 30, + cooldownMinutes: 5, + channels: [userChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + const otherRule = await repo.createAlertRule({ + userId: "user-2", + hostId: null, + name: "Memory high", + enabled: true, + triggerType: "memory_threshold", + thresholdValue: 90, + thresholdDurationSeconds: 60, + cooldownMinutes: 10, + channels: [otherChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + await repo.createFiring({ + userId: "user-1", + ruleId: userRule.id, + hostId: 1, + hostName: "alpha", + value: 95, + message: "high", + severity: "warning", + }); + await repo.createFiring({ + userId: "user-2", + ruleId: otherRule.id, + hostId: 1, + hostName: "alpha", + value: 91, + message: "other", + severity: "warning", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toEqual({ + firingsDeleted: 1, + ruleLinksDeleted: 1, + rulesDeleted: 1, + channelsDeleted: 1, + }); + await expect(repo.deleteByUserId("missing")).resolves.toEqual({ + firingsDeleted: 0, + ruleLinksDeleted: 0, + rulesDeleted: 0, + channelsDeleted: 0, + }); + + expect(await repo.listNotificationChannels("user-1")).toEqual([]); + expect(await repo.listAlertRules("user-1")).toEqual([]); + expect( + await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }), + ).toEqual({ firings: [], total: 0 }); + expect(await repo.listNotificationChannels("user-2")).toHaveLength(1); + expect(await repo.listAlertRules("user-2")).toHaveLength(1); + expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1); + expect(writes).toBe(7); + }); +}); diff --git a/src/backend/tests/database/repositories/api-key-repository.test.ts b/src/backend/tests/database/repositories/api-key-repository.test.ts new file mode 100644 index 00000000..888460f5 --- /dev/null +++ b/src/backend/tests/database/repositories/api-key-repository.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { ApiKeyRepository } from "../../../database/repositories/api-key-repository.js"; + +describe("ApiKeyRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite?: () => void): Promise<{ + apiKeys: ApiKeyRepository; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT, + last_used_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'admin', 'hash'), + ('user-2', 'target', 'hash'); + `); + + return { + apiKeys: new ApiKeyRepository(context, onWrite), + }; + } + + it("creates, lists, finds, updates last used time, and deletes keys", async () => { + const repo = await createRepository(); + + await repo.apiKeys.create({ + id: "key-1", + userId: "user-2", + name: "deploy", + tokenHash: "hash", + tokenPrefix: "tmx_12345678", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: null, + lastUsedAt: null, + isActive: true, + }); + + expect((await repo.apiKeys.findById("key-1"))?.name).toBe("deploy"); + expect( + (await repo.apiKeys.listActiveByTokenPrefix("tmx_12345678")).map( + (key) => key.id, + ), + ).toEqual(["key-1"]); + expect(await repo.apiKeys.listAllWithUsers()).toMatchObject([ + { + id: "key-1", + userId: "user-2", + username: "target", + tokenPrefix: "tmx_12345678", + }, + ]); + + await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z"); + expect((await repo.apiKeys.findById("key-1"))?.lastUsedAt).toBe( + "2026-06-26T01:00:00.000Z", + ); + + expect((await repo.apiKeys.delete("key-1"))?.name).toBe("deploy"); + expect(await repo.apiKeys.findById("key-1")).toBeNull(); + }); + + it("runs the write hook after key writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.apiKeys.create({ + id: "key-1", + userId: "user-1", + name: "ops", + tokenHash: "hash", + tokenPrefix: "tmx_87654321", + isActive: true, + }); + await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z"); + await repo.apiKeys.delete("key-1"); + + expect(writeCount).toBe(3); + }); + + it("deletes all API keys for a user", async () => { + const repo = await createRepository(); + + await repo.apiKeys.create({ + id: "key-1", + userId: "user-2", + name: "deploy", + tokenHash: "hash-1", + tokenPrefix: "tmx_11111111", + isActive: true, + }); + await repo.apiKeys.create({ + id: "key-2", + userId: "user-2", + name: "ops", + tokenHash: "hash-2", + tokenPrefix: "tmx_22222222", + isActive: true, + }); + await repo.apiKeys.create({ + id: "key-3", + userId: "user-1", + name: "admin", + tokenHash: "hash-3", + tokenPrefix: "tmx_33333333", + isActive: true, + }); + + await expect(repo.apiKeys.deleteByUserId("user-2")).resolves.toBe(2); + + expect(await repo.apiKeys.findById("key-1")).toBeNull(); + expect(await repo.apiKeys.findById("key-2")).toBeNull(); + expect((await repo.apiKeys.findById("key-3"))?.userId).toBe("user-1"); + }); +}); diff --git a/src/backend/tests/database/repositories/audit-log-repository.test.ts b/src/backend/tests/database/repositories/audit-log-repository.test.ts new file mode 100644 index 00000000..2f1fed7a --- /dev/null +++ b/src/backend/tests/database/repositories/audit-log-repository.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { AuditLogRepository } from "../../../database/repositories/audit-log-repository.js"; + +describe("AuditLogRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + resource_name TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + error_message TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new AuditLogRepository(context, onWrite); + } + + it("creates, filters, pages, and lists actions", async () => { + const repo = await createRepository(); + + await repo.create({ + userId: "user-1", + username: "alice", + action: "create_host", + resourceType: "host", + resourceId: "1", + success: true, + timestamp: "2026-06-27T00:00:00.000Z", + }); + await repo.create({ + userId: "user-2", + username: "bob", + action: "delete_host", + resourceType: "host", + resourceId: "2", + success: false, + timestamp: "2026-06-27T01:00:00.000Z", + }); + + const page = await repo.listPage({ + filters: { + resourceType: "host", + success: false, + startDate: "2026-06-27T00:30:00.000Z", + }, + limit: 10, + offset: 0, + }); + + expect(page.total).toBe(1); + expect(page.logs[0]).toMatchObject({ + userId: "user-2", + action: "delete_host", + success: false, + }); + expect(await repo.listDistinctActions()).toEqual([ + "create_host", + "delete_host", + ]); + }); + + it("deletes logs by user id and only runs write hook for deleted rows", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create({ + userId: "user-1", + username: "alice", + action: "login", + resourceType: "auth", + success: true, + }); + await repo.create({ + userId: "user-2", + username: "bob", + action: "login", + resourceType: "auth", + success: true, + }); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(2); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(3); + expect( + ( + await repo.listPage({ + filters: {}, + limit: 10, + offset: 0, + }) + ).logs.map((log) => log.userId), + ).toEqual(["user-2"]); + }); +}); diff --git a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts new file mode 100644 index 00000000..0e3196d7 --- /dev/null +++ b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { C2sTunnelPresetRepository } from "../../../database/repositories/c2s-tunnel-preset-repository.js"; + +describe("C2sTunnelPresetRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE c2s_tunnel_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + config TEXT NOT NULL, + platform TEXT, + computer_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new C2sTunnelPresetRepository(context, onWrite); + } + + it("creates and lists presets ordered by name", async () => { + const repo = await createRepository(); + + await repo.createForUser("user-1", { + name: "Zulu", + config: "[]", + platform: "linux", + computerName: "workstation", + }); + await repo.createForUser("user-1", { name: "Alpha", config: "[]" }); + await repo.createForUser("user-2", { name: "Other", config: "[]" }); + + const presets = await repo.listByUserId("user-1"); + + expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]); + expect(presets[1]).toMatchObject({ + platform: "linux", + computerName: "workstation", + }); + }); + + it("finds, updates, and deletes user-owned presets", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const preset = await repo.createForUser("user-1", { + name: "Home", + config: "[]", + }); + expect(writeCount).toBe(1); + + expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull(); + expect(await repo.hasNameForUser("user-1", "Home")).toBe(true); + expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false); + + const updated = await repo.updateForUser("user-1", preset.id, { + name: "Renamed", + platform: "darwin", + }); + expect(updated).toMatchObject({ + id: preset.id, + name: "Renamed", + platform: "darwin", + }); + expect(writeCount).toBe(2); + + expect( + await repo.updateForUser("user-2", preset.id, { name: "Nope" }), + ).toBeNull(); + expect(writeCount).toBe(2); + + expect(await repo.deleteForUser("user-2", preset.id)).toBe(false); + expect(await repo.deleteForUser("user-1", preset.id)).toBe(true); + expect(writeCount).toBe(3); + }); + + it("deletes all presets for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.createForUser("user-1", { name: "One", config: "[]" }); + await repo.createForUser("user-1", { name: "Two", config: "[]" }); + await repo.createForUser("user-2", { name: "Other", config: "[]" }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserId("user-1")).toEqual([]); + expect( + (await repo.listByUserId("user-2")).map((preset) => preset.name), + ).toEqual(["Other"]); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/tests/database/repositories/command-history-repository.test.ts b/src/backend/tests/database/repositories/command-history-repository.test.ts new file mode 100644 index 00000000..ea58d029 --- /dev/null +++ b/src/backend/tests/database/repositories/command-history-repository.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { CommandHistoryRepository } from "../../../database/repositories/command-history-repository.js"; + +describe("CommandHistoryRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE command_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + command TEXT NOT NULL, + executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + `); + + return new CommandHistoryRepository(context, onWrite); + } + + it("creates and lists unique commands by latest execution", async () => { + const repo = await createRepository(); + + await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z"); + await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z"); + await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z"); + await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z"); + + expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([ + "ls", + "pwd", + ]); + expect(await repo.listCommandsForHost("user-1", 1)).toEqual([ + "ls", + "pwd", + "ls", + ]); + }); + + it("deletes commands by command, host, host list, and user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create("user-1", 1, "ls"); + await repo.create("user-1", 1, "ls"); + await repo.create("user-1", 2, "pwd"); + await repo.create("user-2", 3, "whoami"); + expect(writeCount).toBe(4); + + expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0); + expect(writeCount).toBe(4); + + expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([3])).toBe(1); + expect(writeCount).toBe(7); + + await repo.create("user-1", 1, "date"); + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(9); + }); +}); diff --git a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts new file mode 100644 index 00000000..e3bf7a67 --- /dev/null +++ b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { DashboardServiceLinkRepository } from "../../../database/repositories/dashboard-service-link-repository.js"; + +describe("DashboardServiceLinkRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE dashboard_service_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + label TEXT NOT NULL, + url TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new DashboardServiceLinkRepository(context, onWrite); + } + + it("creates and lists links ordered by order then id", async () => { + const repo = await createRepository(); + + const first = await repo.createForUser( + "user-1", + { label: "Docs", url: "https://docs.example.com" }, + "2026-06-27T00:00:00.000Z", + ); + const second = await repo.createForUser("user-1", { + label: "Status", + url: "https://status.example.com", + }); + await repo.createForUser("user-2", { + label: "Other", + url: "https://other.example.com", + }); + + expect(first).toMatchObject({ + userId: "user-1", + label: "Docs", + order: 0, + createdAt: "2026-06-27T00:00:00.000Z", + }); + expect(second.order).toBe(1); + expect( + (await repo.listByUserId("user-1")).map((link) => link.label), + ).toEqual(["Docs", "Status"]); + }); + + it("finds, updates, and deletes a user-owned link", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const link = await repo.createForUser("user-1", { + label: "Docs", + url: "https://docs.example.com", + }); + expect(writeCount).toBe(1); + + expect(await repo.findByIdForUser("user-2", link.id)).toBeNull(); + const updated = await repo.updateForUser("user-1", link.id, { + label: "Docs renamed", + }); + expect(updated).toMatchObject({ + id: link.id, + label: "Docs renamed", + url: "https://docs.example.com", + }); + expect(writeCount).toBe(2); + + expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe( + null, + ); + expect(writeCount).toBe(2); + + expect(await repo.deleteForUser("user-2", link.id)).toBe(false); + expect(await repo.deleteForUser("user-1", link.id)).toBe(true); + expect(writeCount).toBe(3); + }); + + it("deletes all dashboard links for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.createForUser("user-1", { + label: "Docs", + url: "https://docs.example.com", + }); + await repo.createForUser("user-1", { + label: "Status", + url: "https://status.example.com", + }); + await repo.createForUser("user-2", { + label: "Other", + url: "https://other.example.com", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserId("user-1")).toEqual([]); + expect( + (await repo.listByUserId("user-2")).map((link) => link.label), + ).toEqual(["Other"]); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts new file mode 100644 index 00000000..9cbff302 --- /dev/null +++ b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { DismissedAlertRepository } from "../../../database/repositories/dismissed-alert-repository.js"; + +describe("DismissedAlertRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE dismissed_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + alert_id TEXT NOT NULL, + dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new DismissedAlertRepository(context, onWrite); + } + + it("creates, lists, and finds dismissed alerts by user", async () => { + const repo = await createRepository(); + + await repo.create("user-1", "alert-1"); + await repo.create("user-1", "alert-2"); + await repo.create("user-2", "alert-3"); + + expect(await repo.listAlertIdsByUserId("user-1")).toEqual([ + "alert-1", + "alert-2", + ]); + expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe( + "alert-1", + ); + expect(await repo.findForUser("user-1", "alert-3")).toBeNull(); + expect( + (await repo.listByUserId("user-1")).map((row) => row.alertId), + ).toEqual(["alert-1", "alert-2"]); + }); + + it("creates import alerts without duplicating user alert pairs", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect( + repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"), + ).resolves.toBe(true); + await expect( + repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"), + ).resolves.toBe(false); + + const alerts = await repo.listByUserId("user-1"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ + alertId: "alert-1", + dismissedAt: "2026-01-01T00:00:00.000Z", + }); + expect(writeCount).toBe(1); + }); + + it("deletes dismissed alerts and only triggers writes for changed rows", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create("user-1", "alert-1"); + await repo.create("user-1", "alert-2"); + await repo.create("user-2", "alert-3"); + expect(writeCount).toBe(3); + + expect(await repo.deleteForUser("user-1", "missing")).toBe(false); + expect(writeCount).toBe(3); + + expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true); + expect(writeCount).toBe(4); + expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(5); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(writeCount).toBe(5); + }); +}); diff --git a/src/backend/tests/database/repositories/field-encryption-boundary.test.ts b/src/backend/tests/database/repositories/field-encryption-boundary.test.ts new file mode 100644 index 00000000..a7dd89d7 --- /dev/null +++ b/src/backend/tests/database/repositories/field-encryption-boundary.test.ts @@ -0,0 +1,103 @@ +import crypto from "crypto"; +import { describe, expect, it } from "vitest"; +import { FieldEncryptionBoundary } from "../../../database/repositories/field-encryption-boundary.js"; + +describe("FieldEncryptionBoundary", () => { + const userDataKey = crypto.randomBytes(32); + + it("encrypts sensitive host fields while leaving queryable metadata plaintext", () => { + const host = { + id: 42, + userId: "user-1", + name: "prod-db", + ip: "10.0.0.5", + username: "root", + password: "secret", + rdpPassword: "rdp-secret", + }; + + const encrypted = FieldEncryptionBoundary.encryptRecord( + "ssh_data", + host, + userDataKey, + ); + + expect(encrypted.password).not.toBe("secret"); + expect(encrypted.rdpPassword).not.toBe("rdp-secret"); + expect(encrypted.ip).toBe("10.0.0.5"); + expect(encrypted.name).toBe("prod-db"); + + const decrypted = FieldEncryptionBoundary.decryptRecord( + "ssh_data", + encrypted, + userDataKey, + ); + expect(decrypted).toMatchObject(host); + }); + + it("encrypts credential secret fields and keeps metadata plaintext", () => { + const credential = { + id: 7, + userId: "user-1", + name: "primary credential", + authType: "key", + key: "private-key-material", + keyPassword: "key-password", + }; + + const encrypted = FieldEncryptionBoundary.encryptRecord( + "ssh_credentials", + credential, + userDataKey, + ); + + expect(encrypted.key).not.toBe("private-key-material"); + expect(encrypted.keyPassword).not.toBe("key-password"); + expect(encrypted.name).toBe("primary credential"); + + expect( + FieldEncryptionBoundary.decryptRecord( + "ssh_credentials", + encrypted, + userDataKey, + ), + ).toMatchObject(credential); + }); + + it("keeps empty and non-string sensitive values unchanged", () => { + const encrypted = FieldEncryptionBoundary.encryptRecord( + "ssh_data", + { + id: 1, + password: "", + key: null, + }, + userDataKey, + ); + + expect(encrypted.password).toBe(""); + expect(encrypted.key).toBeNull(); + }); + + it("requires a stable record id instead of inventing a temporary encryption context", () => { + expect(() => + FieldEncryptionBoundary.encryptRecord( + "ssh_data", + { password: "secret" }, + userDataKey, + ), + ).toThrow(/stable record id/); + }); + + it("classifies sensitive, plaintext, and unknown fields", () => { + expect(FieldEncryptionBoundary.classifyField("ssh_data", "password")).toBe( + "sensitive", + ); + expect(FieldEncryptionBoundary.classifyField("ssh_data", "ip")).toBe( + "plaintext", + ); + expect(FieldEncryptionBoundary.classifyField("ssh_data", "newField")).toBe( + "unknown", + ); + }); +}); diff --git a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts new file mode 100644 index 00000000..5ae4405b --- /dev/null +++ b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { FileManagerBookmarkRepository } from "../../../database/repositories/file-manager-bookmark-repository.js"; + +describe("FileManagerBookmarkRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE file_manager_recent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE file_manager_pinned ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE file_manager_shortcuts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + `); + + return new FileManagerBookmarkRepository(context, onWrite); + } + + it("upserts and lists recent files by last-opened time", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertRecent( + "user-1", + { hostId: 1, path: "/var/log/app.log" }, + "2026-01-01T00:00:00.000Z", + ); + await repo.upsertRecent( + "user-1", + { hostId: 1, path: "/opt/app/config.json", name: "Config" }, + "2026-01-02T00:00:00.000Z", + ); + await repo.upsertRecent( + "user-1", + { hostId: 1, path: "/var/log/app.log" }, + "2026-01-03T00:00:00.000Z", + ); + + const recent = await repo.listRecentForHost("user-1", 1); + + expect(recent.map((entry) => entry.path)).toEqual([ + "/var/log/app.log", + "/opt/app/config.json", + ]); + expect(recent[0].lastOpened).toBe("2026-01-03T00:00:00.000Z"); + expect(recent[0].name).toBe("app.log"); + expect(writeCount).toBe(3); + + expect( + await repo.deleteRecentForHostPath("user-1", { + hostId: 1, + path: "/var/log/app.log", + }), + ).toBe(1); + expect(writeCount).toBe(4); + }); + + it("creates pinned files and shortcuts without duplicating paths", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.createPinned( + "user-1", + { hostId: 1, path: "/srv/www", name: "Web" }, + "2026-01-01T00:00:00.000Z", + ), + ).toBe(true); + expect( + await repo.createPinned("user-1", { hostId: 1, path: "/srv/www" }), + ).toBe(false); + expect((await repo.listPinnedForHost("user-1", 1))[0]).toMatchObject({ + name: "Web", + path: "/srv/www", + }); + + expect( + await repo.createShortcut( + "user-1", + { hostId: 1, path: "/etc/nginx" }, + "2026-01-02T00:00:00.000Z", + ), + ).toBe(true); + expect( + await repo.createShortcut("user-1", { hostId: 1, path: "/etc/nginx" }), + ).toBe(false); + expect((await repo.listShortcutsForHost("user-1", 1))[0]).toMatchObject({ + name: "nginx", + path: "/etc/nginx", + }); + expect(writeCount).toBe(2); + + expect( + await repo.deletePinnedForHostPath("user-1", { + hostId: 1, + path: "/srv/www", + }), + ).toBe(1); + expect( + await repo.deleteShortcutForHostPath("user-1", { + hostId: 1, + path: "/etc/nginx", + }), + ).toBe(1); + expect(writeCount).toBe(4); + }); + + it("creates import bookmarks without duplicating user path/name pairs", async () => { + const repo = await createRepository(); + + await expect( + repo.createRecentForImport( + "user-1", + { hostId: 1, path: "/tmp/a.txt", name: "A" }, + "2026-01-01T00:00:00.000Z", + ), + ).resolves.toBe(true); + await expect( + repo.createRecentForImport("user-1", { + hostId: 2, + path: "/tmp/a.txt", + name: "A", + }), + ).resolves.toBe(false); + + await expect( + repo.createPinnedForImport("user-1", { + hostId: 1, + path: "/srv/www", + name: "Web", + }), + ).resolves.toBe(true); + await expect( + repo.createPinnedForImport("user-1", { + hostId: 2, + path: "/srv/www", + name: "Web", + }), + ).resolves.toBe(false); + + await expect( + repo.createShortcutForImport("user-1", { + hostId: 1, + path: "/etc/nginx", + name: "Nginx", + }), + ).resolves.toBe(true); + await expect( + repo.createShortcutForImport("user-1", { + hostId: 2, + path: "/etc/nginx", + name: "Nginx", + }), + ).resolves.toBe(false); + }); + + it("deletes bookmarks by user, host, and host list", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertRecent("user-1", { hostId: 1, path: "/one" }); + await repo.createPinned("user-1", { hostId: 2, path: "/two" }); + await repo.createShortcut("user-2", { hostId: 3, path: "/three" }); + expect(writeCount).toBe(3); + + expect(await repo.deleteByHostId(1)).toBe(1); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2])).toBe(1); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByUserId("user-2")).toBe(0); + expect(writeCount).toBe(6); + }); +}); diff --git a/src/backend/tests/database/repositories/homepage-item-repository.test.ts b/src/backend/tests/database/repositories/homepage-item-repository.test.ts new file mode 100644 index 00000000..09a5dc27 --- /dev/null +++ b/src/backend/tests/database/repositories/homepage-item-repository.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { HomepageItemRepository } from "../../../database/repositories/homepage-item-repository.js"; + +describe("HomepageItemRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE homepage_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type_id TEXT NOT NULL, + title TEXT, + config TEXT NOT NULL DEFAULT '{}', + folder_id INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new HomepageItemRepository(context, onWrite); + } + + it("creates and lists homepage items by id", async () => { + const repo = await createRepository(); + + const first = await repo.createForUser( + "user-1", + { typeId: "clock", title: "Clock", config: "{}" }, + "2026-06-27T00:00:00.000Z", + ); + const second = await repo.createForUser("user-1", { + typeId: "terminal", + title: null, + config: '{"hostId":1}', + }); + await repo.createForUser("user-2", { + typeId: "other", + title: "Other", + config: "{}", + }); + + expect(first).toMatchObject({ + userId: "user-1", + typeId: "clock", + title: "Clock", + config: "{}", + createdAt: "2026-06-27T00:00:00.000Z", + }); + expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([ + first.id, + second.id, + ]); + }); + + it("finds, updates, and deletes user-owned homepage items", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const item = await repo.createForUser("user-1", { + typeId: "clock", + title: "Clock", + config: "{}", + }); + expect(writeCount).toBe(1); + + expect(await repo.findByIdForUser("user-2", item.id)).toBeNull(); + const updated = await repo.updateForUser( + "user-1", + item.id, + { title: "Clock renamed", config: '{"timezone":"UTC"}' }, + "2026-06-27T01:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: item.id, + title: "Clock renamed", + config: '{"timezone":"UTC"}', + updatedAt: "2026-06-27T01:00:00.000Z", + }); + expect(writeCount).toBe(2); + + expect( + await repo.updateForUser("user-2", item.id, { title: "Nope" }), + ).toBeNull(); + expect(writeCount).toBe(2); + + expect(await repo.deleteForUser("user-2", item.id)).toBe(false); + expect(await repo.deleteForUser("user-1", item.id)).toBe(true); + expect(writeCount).toBe(3); + }); + + it("deletes all homepage items for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.createForUser("user-1", { + typeId: "clock", + title: "Clock", + config: "{}", + }); + await repo.createForUser("user-1", { + typeId: "terminal", + title: "Terminal", + config: "{}", + }); + await repo.createForUser("user-2", { + typeId: "other", + title: "Other", + config: "{}", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserId("user-1")).toEqual([]); + expect( + (await repo.listByUserId("user-2")).map((item) => item.title), + ).toEqual(["Other"]); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts new file mode 100644 index 00000000..479ae067 --- /dev/null +++ b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { HomepageLayoutRepository } from "../../../database/repositories/homepage-layout-repository.js"; + +describe("HomepageLayoutRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE homepage_layouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + layout TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new HomepageLayoutRepository(context, onWrite); + } + + it("finds, creates, and updates a layout by user id", async () => { + const repo = await createRepository(); + + expect(await repo.findByUserId("user-1")).toBeNull(); + + const created = await repo.upsertForUser( + "user-1", + JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }), + "2026-06-27T00:00:00.000Z", + ); + expect(created).toMatchObject({ + userId: "user-1", + layout: '{"entries":[{"id":"w1"}],"zoom":1}', + updatedAt: "2026-06-27T00:00:00.000Z", + }); + + const updated = await repo.upsertForUser( + "user-1", + JSON.stringify({ entries: [], zoom: 1.25 }), + "2026-06-27T01:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: created.id, + userId: "user-1", + layout: '{"entries":[],"zoom":1.25}', + updatedAt: "2026-06-27T01:00:00.000Z", + }); + }); + + it("triggers writes after create and update", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForUser("user-1", "{}"); + await repo.upsertForUser("user-1", '{"zoom":2}'); + + expect(writeCount).toBe(2); + }); + + it("deletes a layout for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForUser("user-1", '{"zoom":1}'); + await repo.upsertForUser("user-2", '{"zoom":2}'); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findByUserId("user-1")).toBeNull(); + expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}'); + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts new file mode 100644 index 00000000..3973618f --- /dev/null +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -0,0 +1,691 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { CredentialRepository } from "../../../database/repositories/credential-repository.js"; +import { HostRepository } from "../../../database/repositories/host-repository.js"; +import { DataCrypto } from "../../../utils/data-crypto.js"; + +describe("HostRepository and CredentialRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + vi.restoreAllMocks(); + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepositories( + onCredentialWrite?: () => void, + onHostWrite?: () => void, + ): Promise<{ + credentials: CredentialRepository; + hosts: HostRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT, + password TEXT, + key TEXT, + private_key TEXT, + public_key TEXT, + key_password TEXT, + key_type TEXT, + detected_key_type TEXT, + cert_public_key TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER, + FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (override_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL + ); + + CREATE TABLE ssh_credential_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user', 'hash'), + ('user-2', 'other', 'hash'); + `); + + return { + credentials: new CredentialRepository(context, onCredentialWrite), + hosts: new HostRepository(context, onHostWrite), + sqlite: context.sqlite!, + }; + } + + it("creates, finds, updates, lists, and deletes credentials", async () => { + const repo = await createRepositories(); + + const created = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + folder: "prod", + }); + + expect(created.id).toBeGreaterThan(0); + expect(await repo.credentials.listFolders("user-1")).toEqual(["prod"]); + expect( + (await repo.credentials.findByIdForUser("user-1", created.id))?.name, + ).toBe("primary"); + expect((await repo.credentials.findById(created.id))?.name).toBe("primary"); + + const updated = await repo.credentials.updateForUser("user-1", created.id, { + folder: "ops", + tags: "linux,admin", + }); + expect(updated?.folder).toBe("ops"); + + expect( + await repo.credentials.findByIdForUser("user-2", created.id), + ).toBeNull(); + expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe( + true, + ); + expect( + await repo.credentials.findByIdForUser("user-1", created.id), + ).toBeNull(); + }); + + it("deletes user credentials through the cleanup boundary", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(onWrite); + + await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + }); + await repo.credentials.create({ + userId: "user-1", + name: "secondary", + authType: "key", + }); + await repo.credentials.create({ + userId: "user-2", + name: "other", + authType: "password", + }); + onWrite.mockClear(); + + await expect(repo.credentials.deleteByUserId("user-1")).resolves.toBe(2); + + expect(await repo.credentials.listByUserId("user-1")).toEqual([]); + expect((await repo.credentials.listByUserId("user-2")).length).toBe(1); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("loads credentials through the decryption boundary", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "decryptRecords").mockImplementation( + (_tableName, records) => records, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => record, + ); + + const created = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + folder: "prod", + }); + + await expect( + repo.credentials.listDecryptedByUserId("user-1"), + ).resolves.toMatchObject([{ id: created.id, password: "secret" }]); + await expect( + repo.credentials.findDecryptedByIdForUser("user-1", created.id), + ).resolves.toMatchObject({ id: created.id, password: "secret" }); + expect(DataCrypto.decryptRecords).toHaveBeenCalledWith( + "ssh_credentials", + expect.arrayContaining([expect.objectContaining({ id: created.id })]), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ id: created.id }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("encrypts credential writes with the user key", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "encryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + password: "user-encrypted-password", + }) as typeof record, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => record, + ); + + const created = await repo.credentials.createEncryptedForUser("user-1", { + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + }); + + const raw = repo.sqlite + .prepare("SELECT password FROM ssh_credentials WHERE id = ?") + .get(created.id) as { password: string }; + + expect(raw.password).toBe("user-encrypted-password"); + + await repo.credentials.updateEncryptedForUser("user-1", created.id, { + password: "updated-secret", + }); + + const updatedRaw = repo.sqlite + .prepare("SELECT password FROM ssh_credentials WHERE id = ?") + .get(created.id) as { password: string }; + + expect(updatedRaw.password).toBe("user-encrypted-password"); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ password: "updated-secret" }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("checks credential import identity", async () => { + const repo = await createRepositories(); + + await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + }); + + await expect( + repo.credentials.existsForImportIdentity("user-1", "primary", "root"), + ).resolves.toBe(true); + await expect( + repo.credentials.existsForImportIdentity("user-1", "primary", "admin"), + ).resolves.toBe(false); + }); + + it("renames credential folders through the write boundary", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(onWrite); + + await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + folder: "prod", + }); + await repo.credentials.create({ + userId: "user-1", + name: "secondary", + authType: "key", + folder: "prod", + }); + await repo.credentials.create({ + userId: "user-2", + name: "other", + authType: "password", + folder: "prod", + }); + onWrite.mockClear(); + + await expect( + repo.credentials.renameFolder("user-1", "prod", "ops"), + ).resolves.toBe(2); + + expect(await repo.credentials.listFolders("user-1")).toEqual(["ops"]); + expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("returns empty credential reads when user data is locked", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(null); + + const created = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + }); + + await expect( + repo.credentials.listDecryptedByUserId("user-1"), + ).resolves.toEqual([]); + await expect( + repo.credentials.findDecryptedByIdForUser("user-1", created.id), + ).resolves.toBeNull(); + }); + + it("creates, finds, updates, lists, and deletes hosts", async () => { + const repo = await createRepositories(); + + const host = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + }); + + expect(host.id).toBeGreaterThan(0); + expect((await repo.hosts.findById(host.id))?.name).toBe("web-1"); + expect( + (await repo.hosts.listByUserId("user-1")).map((item) => item.id), + ).toEqual([host.id]); + + const updated = await repo.hosts.updateForUser("user-1", host.id, { + name: "web-1-renamed", + folder: "prod", + }); + expect(updated?.name).toBe("web-1-renamed"); + expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull(); + + expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); + expect(await repo.hosts.findById(host.id)).toBeNull(); + }); + + it("encrypts host writes through the repository boundary", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "encryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + password: "encrypted-host-password", + }) as typeof record, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => record, + ); + + const created = await repo.hosts.createEncryptedForUser("user-1", { + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + password: "secret", + }); + + const raw = repo.sqlite + .prepare("SELECT password FROM ssh_data WHERE id = ?") + .get(created.id) as { password: string }; + + expect(raw.password).toBe("encrypted-host-password"); + + await repo.hosts.updateEncryptedForUser("user-1", created.id, { + password: "updated-secret", + }); + + const updatedRaw = repo.sqlite + .prepare("SELECT password FROM ssh_data WHERE id = ?") + .get(created.id) as { password: string }; + + expect(updatedRaw.password).toBe("encrypted-host-password"); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ password: "updated-secret" }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("loads hosts through the decryption boundary", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "decryptRecords").mockImplementation( + (_tableName, records) => records, + ); + + const host = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + password: "secret", + }); + + await expect( + repo.hosts.listDecryptedByUserId("user-1"), + ).resolves.toMatchObject([{ id: host.id, password: "secret" }]); + expect(DataCrypto.decryptRecords).toHaveBeenCalledWith( + "ssh_data", + expect.arrayContaining([expect.objectContaining({ id: host.id })]), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("checks host import identity", async () => { + const repo = await createRepositories(); + + await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + }); + + await expect( + repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 22, "root"), + ).resolves.toBe(true); + await expect( + repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 2222, "root"), + ).resolves.toBe(false); + }); + + it("deletes user hosts through the cleanup boundary", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(undefined, onWrite); + + await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + }); + await repo.hosts.create({ + userId: "user-1", + name: "web-2", + ip: "10.0.0.11", + port: 22, + username: "root", + authType: "password", + }); + await repo.hosts.create({ + userId: "user-2", + name: "other", + ip: "10.0.0.12", + port: 22, + username: "root", + authType: "password", + }); + onWrite.mockClear(); + + await expect(repo.hosts.deleteByUserId("user-1")).resolves.toBe(2); + + expect(await repo.hosts.listByUserId("user-1")).toEqual([]); + expect((await repo.hosts.listByUserId("user-2")).length).toBe(1); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("lists bulk update state and updates multiple owned hosts", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(undefined, onWrite); + + const first = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + statsConfig: JSON.stringify({ cpu: true }), + }); + const second = await repo.hosts.create({ + userId: "user-1", + name: "web-2", + ip: "10.0.0.11", + port: 22, + username: "root", + authType: "password", + }); + const other = await repo.hosts.create({ + userId: "user-2", + name: "other", + ip: "10.0.0.12", + port: 22, + username: "root", + authType: "password", + }); + onWrite.mockClear(); + + const states = await repo.hosts.listBulkUpdateState("user-1", [ + first.id, + second.id, + other.id, + ]); + expect(states.map((state) => state.id)).toEqual([first.id, second.id]); + + await expect( + repo.hosts.updateManyForUser("user-1", [first.id, second.id, other.id], { + folder: "ops", + }), + ).resolves.toBe(2); + expect((await repo.hosts.findById(first.id))?.folder).toBe("ops"); + expect((await repo.hosts.findById(other.id))?.folder).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("records credential usage and increments usage counters", async () => { + const repo = await createRepositories(); + const credential = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + }); + const host = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "credential", + credentialId: credential.id, + }); + + await repo.credentials.recordUsage( + "user-1", + credential.id, + host.id, + "2026-06-26T00:00:00.000Z", + ); + + const updated = await repo.credentials.findByIdForUser( + "user-1", + credential.id, + ); + expect(updated?.usageCount).toBe(1); + expect(updated?.lastUsed).toBe("2026-06-26T00:00:00.000Z"); + }); + + it("cleans host access before deleting a host", async () => { + const repo = await createRepositories(); + const host = await repo.hosts.create({ + userId: "user-1", + name: "shared-host", + ip: "10.0.0.20", + port: 22, + username: "root", + authType: "password", + }); + + repo.sqlite + .prepare( + "INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)", + ) + .run(host.id, "user-2", "user-1"); + + expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1); + expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); + }); +}); diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts new file mode 100644 index 00000000..ca7df0c6 --- /dev/null +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -0,0 +1,276 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js"; + +describe("HostFolderRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: HostFolderRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT, + auth_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type) + VALUES + (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'), + (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'prod / api', 'password'), + (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'prod', 'password'); + INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type) + VALUES + (1, 'user-1', 'cred-one', 'prod', 'password'), + (2, 'user-1', 'cred-two', 'prod / api', 'password'), + (3, 'user-2', 'cred-other', 'prod', 'password'); + INSERT INTO ssh_folders (id, user_id, name, color, icon) + VALUES + (1, 'user-1', 'prod', '#111111', 'server'), + (2, 'user-1', 'prod / api', '#222222', 'box'), + (3, 'user-2', 'prod', '#333333', 'user'); + `); + + return { + repository: new HostFolderRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("renames folders across hosts, credentials, and folder records", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + await expect( + repository.renameFolder( + "user-1", + "prod", + "ops", + "2026-01-01T00:00:00.000Z", + ), + ).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 }); + + expect( + sqlite + .prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id") + .all("user-1"), + ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); + expect( + sqlite + .prepare( + "SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id", + ) + .all("user-1"), + ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); + expect( + sqlite + .prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id") + .all("user-1"), + ).toEqual([{ name: "ops" }, { name: "ops / api" }]); + expect(writes).toBe(1); + }); + + it("lists folders and upserts metadata", async () => { + let writes = 0; + const { repository } = await createRepository(() => { + writes += 1; + }); + + await expect(repository.listFolders("user-1")).resolves.toHaveLength(2); + await expect( + repository.upsertMetadata( + "user-1", + "prod", + "#abcdef", + "folder", + "2026-02-01T00:00:00.000Z", + ), + ).resolves.toMatchObject({ + created: false, + folder: { color: "#abcdef", icon: "folder" }, + }); + await expect( + repository.upsertMetadata( + "user-1", + "new", + null, + null, + "2026-03-01T00:00:00.000Z", + ), + ).resolves.toMatchObject({ + created: true, + folder: { name: "new" }, + }); + expect(writes).toBe(2); + }); + + it("lists and deletes hosts and folder records in a folder tree", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + const hostsToDelete = await repository.listHostsInFolder("user-1", "prod"); + expect(hostsToDelete.map((host) => host.id)).toEqual([1, 2]); + + await repository.deleteHostsAndFolderRecords("user-1", "prod"); + + expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( + [{ id: 3 }], + ); + expect( + sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + ).toEqual([{ id: 3 }]); + expect(writes).toBe(1); + }); + + it("deletes folder records for a user", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + await expect(repository.deleteByUserId("user-1")).resolves.toBe(2); + + expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( + [{ id: 1 }, { id: 2 }, { id: 3 }], + ); + expect( + sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + ).toEqual([{ id: 3 }]); + expect(writes).toBe(1); + }); +}); diff --git a/src/backend/tests/database/repositories/host-health-repository.test.ts b/src/backend/tests/database/repositories/host-health-repository.test.ts new file mode 100644 index 00000000..29ff31f6 --- /dev/null +++ b/src/backend/tests/database/repositories/host-health-repository.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { HostHealthRepository } from "../../../database/repositories/host-health-repository.js"; + +describe("HostHealthRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE host_health_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + checks TEXT NOT NULL, + interval_seconds INTEGER NOT NULL DEFAULT 300, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_health_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + check_id TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ok INTEGER NOT NULL, + latency_ms INTEGER, + detail TEXT + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO host_health_checks ( + user_id, host_id, checks, interval_seconds, created_at, updated_at + ) + VALUES ( + 'user-1', + 1, + '[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]', + 300, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + `); + + return new HostHealthRepository(context, onWrite); + } + + it("finds and upserts check configuration", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const existing = await repo.findChecksByUserAndHost("user-1", 1); + expect(existing?.intervalSeconds).toBe(300); + + const updated = await repo.upsertChecks( + "user-1", + 1, + '[{"id":"http"}]', + 60, + "2026-02-01T00:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: existing?.id, + checks: '[{"id":"http"}]', + intervalSeconds: 60, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + + const created = await repo.upsertChecks( + "user-2", + 2, + '[{"id":"tcp"}]', + 120, + "2026-03-01T00:00:00.000Z", + ); + expect(created).toMatchObject({ + userId: "user-2", + hostId: 2, + checks: '[{"id":"tcp"}]', + intervalSeconds: 120, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(writeCount).toBe(2); + }); + + it("records and prunes history", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.recordHistory( + "user-1", + 1, + [{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }], + 1, + "2026-01-01T00:00:00.000Z", + ), + ).toBe(1); + expect( + await repo.recordHistory( + "user-1", + 1, + [{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }], + 1, + "2026-01-02T00:00:00.000Z", + ), + ).toBe(1); + + const history = await repo.listHistory("user-1", 1, 10); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + userId: "user-1", + hostId: 1, + checkId: "two", + ok: false, + latencyMs: null, + detail: "closed", + }); + expect(writeCount).toBe(2); + }); + + it("deletes all health checks and history for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120); + await repo.recordHistory( + "user-1", + 1, + [{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }], + 10, + ); + await repo.recordHistory( + "user-2", + 2, + [{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }], + 10, + ); + + await expect(repo.deleteByUserId("user-1")).resolves.toEqual({ + checksDeleted: 1, + historyDeleted: 1, + }); + await expect(repo.deleteByUserId("missing")).resolves.toEqual({ + checksDeleted: 0, + historyDeleted: 0, + }); + + expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull(); + expect(await repo.listHistory("user-1", 1, 10)).toEqual([]); + expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2); + expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts new file mode 100644 index 00000000..f20f5c42 --- /dev/null +++ b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { HostMetricsHistoryRepository } from "../../../database/repositories/host-metrics-history-repository.js"; + +describe("HostMetricsHistoryRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE host_metrics_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + cpu_percent REAL, + mem_percent REAL, + disk_percent REAL, + net_rx_bytes INTEGER, + net_tx_bytes INTEGER + ); + + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO host_metrics_history ( + host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes + ) + VALUES + (1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200), + (1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201), + (1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202), + (2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999); + `); + + return new HostMetricsHistoryRepository(context, onWrite); + } + + it("creates and lists metrics history rows by range", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create({ + hostId: 1, + cpuPercent: 12, + memPercent: 22, + diskPercent: 32, + netRxBytes: 102, + netTxBytes: 202, + }); + + const rows = await repo.listRange( + 1, + "2026-01-01 00:00:00", + "2026-01-02 23:59:59", + ); + + expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]); + expect(writeCount).toBe(1); + }); + + it("prunes old history for a host only", async () => { + const repo = await createRepository(); + + repo.pruneOlderThan(1, 1); + + const rows = await repo.listRange( + 1, + "2000-01-01 00:00:00", + "2999-12-31 23:59:59", + ); + expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]); + expect( + await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"), + ).toHaveLength(1); + }); +}); diff --git a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts new file mode 100644 index 00000000..cc1ceeba --- /dev/null +++ b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { HostMetricsPreferenceRepository } from "../../../database/repositories/host-metrics-preference-repository.js"; + +describe("HostMetricsPreferenceRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + stats_config TEXT + ); + + CREATE TABLE host_metrics_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + layout TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, stats_config) + VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}'); + INSERT INTO host_metrics_preferences ( + user_id, host_id, layout, created_at, updated_at + ) + VALUES ( + 'user-1', + 1, + '{"slots":[],"columns":3}', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + `); + + return new HostMetricsPreferenceRepository(context, onWrite); + } + + it("finds a saved layout by user and host", async () => { + const repo = await createRepository(); + + const existing = await repo.findByUserAndHost("user-1", 1); + expect(existing?.layout).toBe('{"slots":[],"columns":3}'); + expect(await repo.findByUserAndHost("user-1", 2)).toBeNull(); + }); + + it("updates and inserts layouts with write notifications", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.upsertLayout( + "user-1", + 1, + '{"slots":[{"id":"cpu"}],"columns":2}', + "2026-02-01T00:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: 1, + layout: '{"slots":[{"id":"cpu"}],"columns":2}', + updatedAt: "2026-02-01T00:00:00.000Z", + }); + + const created = await repo.upsertLayout( + "user-2", + 2, + '{"slots":[{"id":"mem"}],"columns":1}', + "2026-03-01T00:00:00.000Z", + ); + expect(created).toMatchObject({ + userId: "user-2", + hostId: 2, + layout: '{"slots":[{"id":"mem"}],"columns":1}', + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(writeCount).toBe(2); + }); + + it("updates host stats config for the owning user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect( + repo.updateHostStatsConfig( + "user-1", + 1, + '{"enabledWidgets":["cpu","memory"]}', + ), + ).resolves.toBe(true); + await expect( + repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'), + ).resolves.toBe(false); + + expect(writeCount).toBe(1); + }); + + it("deletes all metric preferences for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}'); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); + expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe( + '{"slots":["mem"]}', + ); + expect(writeCount).toBe(2); + }); +}); diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts new file mode 100644 index 00000000..e90eb576 --- /dev/null +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -0,0 +1,495 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { DataCrypto } from "../../../utils/data-crypto.js"; +import { HostResolutionRepository } from "../../../database/repositories/host-resolution-repository.js"; + +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + getUserDataKey: vi.fn(), + decryptRecord: vi.fn((_tableName, record) => record), + }, +})); + +describe("HostResolutionRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReset(); + vi.mocked(DataCrypto.decryptRecord).mockClear(); + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT, + password TEXT, + key TEXT, + private_key TEXT, + public_key TEXT, + key_password TEXT, + key_type TEXT, + detected_key_type TEXT, + cert_public_key TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data ( + id, user_id, name, ip, port, username, auth_type, credential_id, + tunnel_connections + ) + VALUES + (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'), + (2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL), + (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]'); + INSERT INTO ssh_credentials ( + id, user_id, name, auth_type, username, password, private_key, key_password + ) + VALUES + (7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL), + (8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass'); + INSERT INTO host_access ( + host_id, user_id, granted_by, permission_level, override_credential_id + ) + VALUES (1, 'user-2', 'user-1', 'execute', 8); + `); + + return new HostResolutionRepository(context, onWrite); + } + + it("loads host and credential rows through the decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue( + Buffer.from("user-key"), + ); + const repository = await createRepository(); + + await expect(repository.findHostById(1, "user-1")).resolves.toMatchObject({ + id: 1, + userId: "user-1", + name: "web", + credentialId: 7, + }); + await expect( + repository.findHostByIdForUser(1, "user-1"), + ).resolves.toMatchObject({ + id: 1, + userId: "user-1", + name: "web", + credentialId: 7, + }); + await expect( + repository.findHostByIdForUser(3, "user-1"), + ).resolves.toBeNull(); + await expect( + repository.findCredentialByIdForUser(7, "user-1"), + ).resolves.toMatchObject({ + id: 7, + userId: "user-1", + username: "root", + password: "secret", + }); + await expect( + repository.findCredentialByIdForOwnerDecryptedAs(7, "user-1", "user-2"), + ).resolves.toMatchObject({ + id: 7, + userId: "user-1", + username: "root", + password: "secret", + }); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ id: 7 }), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ id: 7 }), + "user-2", + Buffer.from("user-key"), + ); + }); + + it("lists user-owned hosts through the decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue( + Buffer.from("user-key"), + ); + const repository = await createRepository(); + + const rows = await repository.findHostsByUserId("user-1"); + + expect(rows.map((row) => row.id)).toEqual([1, 2]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 2 }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("lists raw own and shared host rows for access list assembly", async () => { + const repository = await createRepository(); + + const rows = await repository.listHostRowsForAccessList("user-2", [ + { hostId: 1, permissionLevel: "execute", expiresAt: null }, + { hostId: 3, permissionLevel: "view", expiresAt: null }, + { hostId: 999, permissionLevel: "view", expiresAt: null }, + ]); + + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + id: 3, + userId: "user-2", + ownerId: "user-2", + isShared: false, + permissionLevel: undefined, + expiresAt: undefined, + }); + expect(rows[1]).toMatchObject({ + id: 1, + userId: "user-1", + ownerId: "user-1", + isShared: true, + permissionLevel: "execute", + expiresAt: null, + }); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("loads host owner metadata without decrypting host data", async () => { + const repository = await createRepository(); + + await expect(repository.findHostOwnerId(1)).resolves.toBe("user-1"); + await expect(repository.findHostOwnerId(999)).resolves.toBeNull(); + await expect(repository.isHostOwnedByUser(1, "user-1")).resolves.toBe(true); + await expect(repository.isHostOwnedByUser(1, "user-2")).resolves.toBe( + false, + ); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("loads host update state without decrypting host data", async () => { + const repository = await createRepository(); + + await expect(repository.findHostUpdateState(1)).resolves.toEqual({ + userId: "user-1", + credentialId: 7, + rdpCredentialId: null, + vncCredentialId: null, + telnetCredentialId: null, + vaultProfileId: null, + authType: "password", + }); + await expect(repository.findHostUpdateState(999)).resolves.toBeNull(); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("lists hosts using a credential through the decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue( + Buffer.from("user-key"), + ); + const repository = await createRepository(); + + const rows = await repository.listHostsUsingCredentialForUser("user-1", 7); + + expect(rows.map((row) => row.id)).toEqual([1]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("lists all hosts through each owner decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) => + Buffer.from(`${userId}-key`), + ); + const repository = await createRepository(); + + const rows = await repository.listAllHosts(); + + expect(rows.map((row) => row.id)).toEqual([1, 2, 3]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-1-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 3 }), + "user-2", + Buffer.from("user-2-key"), + ); + }); + + it("lists tunnel-enabled hosts with tunnel data through each owner decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) => + Buffer.from(`${userId}-key`), + ); + const repository = await createRepository(); + + const rows = await repository.listHostsWithTunnelConnections(); + + expect(rows.map((row) => row.id)).toEqual([1, 3]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-1-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 3 }), + "user-2", + Buffer.from("user-2-key"), + ); + }); + + it("skips owner-scoped host list rows when that user's data is locked", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) => + userId === "user-1" ? Buffer.from("user-1-key") : null, + ); + const repository = await createRepository(); + + const rows = await repository.listAllHosts(); + + expect(rows.map((row) => row.id)).toEqual([1, 2]); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 3 }), + expect.any(String), + expect.any(Buffer), + ); + }); + + it("loads host key verification metadata without decrypting credentials", async () => { + const repository = await createRepository(); + + const row = await repository.findHostKeyVerificationData(1); + + expect(row).toMatchObject({ + hostKeyFingerprint: null, + hostKeyType: null, + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 0, + name: "web", + }); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("stores and updates host key verification metadata through the write boundary", async () => { + const onWrite = vi.fn(); + const repository = await createRepository(onWrite); + + await repository.storeHostKey( + 1, + "fingerprint-1", + "ssh-rsa", + "sha256", + "t1", + ); + await expect( + repository.findHostKeyVerificationData(1), + ).resolves.toMatchObject({ + hostKeyFingerprint: "fingerprint-1", + hostKeyType: "ssh-rsa", + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 0, + }); + + await repository.touchHostKeyLastVerified(1, "t2"); + await repository.updateHostKey( + 1, + "fingerprint-2", + "ssh-ed25519", + "sha256", + 0, + "t3", + ); + + await expect( + repository.findHostKeyVerificationData(1), + ).resolves.toMatchObject({ + hostKeyFingerprint: "fingerprint-2", + hostKeyType: "ssh-ed25519", + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 1, + }); + expect(onWrite).toHaveBeenCalledTimes(3); + }); + + it("returns null when user data is locked", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(null); + const repository = await createRepository(); + + await expect(repository.findHostById(1, "user-1")).resolves.toBeNull(); + await expect( + repository.findHostByIdForUser(1, "user-1"), + ).resolves.toBeNull(); + await expect(repository.findHostsByUserId("user-1")).resolves.toEqual([]); + await expect( + repository.listHostsUsingCredentialForUser("user-1", 7), + ).resolves.toEqual([]); + await expect( + repository.findCredentialByIdForUser(7, "user-1"), + ).resolves.toBeNull(); + }); + + it("loads override credential ids for shared host resolution", async () => { + const repository = await createRepository(); + + await expect( + repository.findOverrideCredentialId(1, "user-2"), + ).resolves.toBe(8); + await expect( + repository.findOverrideCredentialId(1, "user-1"), + ).resolves.toBeNull(); + }); +}); diff --git a/src/backend/tests/database/repositories/network-topology-repository.test.ts b/src/backend/tests/database/repositories/network-topology-repository.test.ts new file mode 100644 index 00000000..1c8ad72a --- /dev/null +++ b/src/backend/tests/database/repositories/network-topology-repository.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { NetworkTopologyRepository } from "../../../database/repositories/network-topology-repository.js"; + +describe("NetworkTopologyRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE network_topology ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + topology TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new NetworkTopologyRepository(context, onWrite); + } + + it("finds, creates, and updates topology by user id", async () => { + const repo = await createRepository(); + + expect(await repo.findByUserId("user-1")).toBeNull(); + + await repo.upsertForUser( + "user-1", + JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }), + "2026-06-27T00:00:00.000Z", + ); + expect(await repo.findByUserId("user-1")).toMatchObject({ + userId: "user-1", + topology: '{"nodes":[{"id":"host-1"}],"edges":[]}', + updatedAt: "2026-06-27T00:00:00.000Z", + }); + + await repo.upsertForUser( + "user-1", + JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }), + "2026-06-27T01:00:00.000Z", + ); + expect(await repo.findByUserId("user-1")).toMatchObject({ + userId: "user-1", + topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}', + updatedAt: "2026-06-27T01:00:00.000Z", + }); + }); + + it("deletes topology and only triggers writes for changed rows", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForUser("user-1", "{}"); + await repo.upsertForUser("user-1", '{"nodes":[]}'); + expect(writeCount).toBe(2); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(2); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(3); + expect(await repo.findByUserId("user-1")).toBeNull(); + }); +}); diff --git a/src/backend/tests/database/repositories/open-tab-repository.test.ts b/src/backend/tests/database/repositories/open-tab-repository.test.ts new file mode 100644 index 00000000..0d7f88c9 --- /dev/null +++ b/src/backend/tests/database/repositories/open-tab-repository.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { OpenTabRepository } from "../../../database/repositories/open-tab-repository.js"; + +describe("OpenTabRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE user_open_tabs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + tab_type TEXT NOT NULL, + host_id INTEGER, + label TEXT NOT NULL, + tab_order INTEGER NOT NULL DEFAULT 0, + backend_session_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new OpenTabRepository(context, onWrite); + } + + it("lists recent tabs ordered by tab order", async () => { + const repo = await createRepository(); + + await repo.upsertForUser( + "user-1", + { + id: "tab-old", + tabType: "terminal", + label: "Old", + tabOrder: 1, + }, + "2026-06-27T00:00:00.000Z", + ); + await repo.upsertForUser( + "user-1", + { + id: "tab-new", + tabType: "stats", + label: "New", + tabOrder: 0, + }, + "2026-06-27T01:00:00.000Z", + ); + await repo.upsertForUser( + "user-2", + { + id: "other", + tabType: "terminal", + label: "Other", + tabOrder: 0, + }, + "2026-06-27T02:00:00.000Z", + ); + + expect( + (await repo.listRecentForUser("user-1", "2026-06-27T00:30:00.000Z")).map( + (tab) => tab.id, + ), + ).toEqual(["tab-new"]); + }); + + it("upserts tabs and preserves backend session when omitted", async () => { + const repo = await createRepository(); + + await repo.upsertForUser("user-1", { + id: "tab-1", + tabType: "terminal", + hostId: 1, + label: "Server", + tabOrder: 0, + backendSessionId: "session-1", + }); + await repo.upsertForUser("user-1", { + id: "tab-1", + tabType: "terminal", + hostId: 2, + label: "Server renamed", + tabOrder: 1, + }); + + expect( + (await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z"))[0], + ).toMatchObject({ + id: "tab-1", + hostId: 2, + label: "Server renamed", + tabOrder: 1, + backendSessionId: "session-1", + }); + }); + + it("replaces, updates, and deletes tabs for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.replaceForUser("user-1", [ + { id: "tab-1", tabType: "terminal", label: "One", tabOrder: 1 }, + { id: "tab-2", tabType: "settings", label: "Two", tabOrder: 0 }, + ]); + expect( + (await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z")).map( + (tab) => tab.id, + ), + ).toEqual(["tab-2", "tab-1"]); + + expect( + await repo.updateForUser("user-1", "missing", { label: "Missing" }), + ).toBe(false); + expect( + await repo.updateForUser("user-1", "tab-1", { label: "Renamed" }), + ).toBe(true); + expect(await repo.deleteForUser("user-1", "tab-2")).toBe(1); + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts new file mode 100644 index 00000000..4700e214 --- /dev/null +++ b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { OpksshTokenRepository } from "../../../database/repositories/opkssh-token-repository.js"; + +describe("OpksshTokenRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE opkssh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + ssh_cert TEXT NOT NULL, + private_key TEXT NOT NULL, + email TEXT, + sub TEXT, + issuer TEXT, + audience TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used TEXT, + UNIQUE(user_id, host_id) + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO opkssh_tokens ( + user_id, host_id, ssh_cert, private_key, email, expires_at + ) + VALUES + ('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'), + ('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'), + ('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z'); + `); + + return new OpksshTokenRepository(context, onWrite); + } + + it("finds and upserts a token by user and host", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const existing = await repo.findByUserAndHost("user-1", 1); + expect(existing?.sshCert).toBe("cert-1"); + + await repo.upsert({ + userId: "user-1", + hostId: 1, + sshCert: "new-cert", + privateKey: "new-key", + email: "new@example.com", + sub: "sub", + issuer: "issuer", + audience: "aud", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + const updated = await repo.findByUserAndHost("user-1", 1); + expect(updated).toMatchObject({ + sshCert: "new-cert", + privateKey: "new-key", + email: "new@example.com", + sub: "sub", + issuer: "issuer", + audience: "aud", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(writeCount).toBe(1); + }); + + it("updates last-used and deletes one token", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"), + ).toBe(true); + expect(await repo.updateLastUsed("missing", 1)).toBe(false); + expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe( + "2026-01-02T00:00:00.000Z", + ); + + expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true); + expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false); + expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes tokens by user id", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteByUserId("user-1")).toBe(2); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); + expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull(); + expect(writeCount).toBe(1); + }); +}); diff --git a/src/backend/tests/database/repositories/rbac-access-repository.test.ts b/src/backend/tests/database/repositories/rbac-access-repository.test.ts new file mode 100644 index 00000000..1cc8e509 --- /dev/null +++ b/src/backend/tests/database/repositories/rbac-access-repository.test.ts @@ -0,0 +1,562 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { RbacAccessRepository } from "../../../database/repositories/rbac-access-repository.js"; + +describe("RbacAccessRepository", () => { + let adapter: TestSqliteDatabase | null = null; + const activeAccessTime = "2026-06-26T12:00:00.000Z"; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + permissions TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER + ); + + CREATE TABLE shared_host_secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_access_id INTEGER NOT NULL, + target_user_id 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, + UNIQUE(host_access_id, target_user_id, protocol) + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + credential_id INTEGER, + rdp_credential_id INTEGER, + vnc_credential_id INTEGER, + telnet_credential_id INTEGER, + folder TEXT, + tags TEXT + ); + + CREATE TABLE snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + folder TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + host_filter TEXT + ); + + CREATE TABLE snippet_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snippet_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash, is_admin, is_oidc) + VALUES + ('admin', 'admin', 'hash', 1, 0), + ('user-1', 'alice', 'hash', 0, 0), + ('owner-1', 'owner', 'hash', 0, 0); + + INSERT INTO roles (id, name, display_name, is_system) + VALUES (7, 'ops', 'Operations', 0); + + INSERT INTO ssh_data ( + id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags + ) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux'); + + INSERT INTO host_access ( + id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at + ) + VALUES + (1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), + (2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'), + (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z'); + + INSERT INTO shared_host_secrets ( + id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type + ) + VALUES + (8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'), + (9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct'); + + INSERT INTO snippets (id, user_id, name, content) + VALUES (99, 'owner-1', 'deploy', 'echo deploy'); + + INSERT INTO snippet_access ( + id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at + ) + VALUES + (3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), + (4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'); + `); + + return new RbacAccessRepository(context, onWrite); + } + + it("lists host access with user and role target metadata", async () => { + const repo = await createRepository(); + + const accessList = await repo.listHostAccess(42); + + expect(accessList).toMatchObject([ + { + id: 2, + targetType: "role", + userId: null, + roleId: 7, + username: null, + roleName: "ops", + roleDisplayName: "Operations", + grantedByUsername: "admin", + }, + { + id: 1, + targetType: "user", + userId: "user-1", + roleId: null, + username: "alice", + roleName: null, + roleDisplayName: null, + grantedByUsername: "admin", + }, + ]); + }); + + it("lists snippet access with user and role target metadata", async () => { + const repo = await createRepository(); + + const accessList = await repo.listSnippetAccess(99); + + expect(accessList.map((access) => access.targetType)).toEqual([ + "role", + "user", + ]); + expect(accessList[0]).toMatchObject({ + id: 4, + roleId: 7, + roleName: "ops", + grantedByUsername: "admin", + }); + expect(accessList[1]).toMatchObject({ + id: 3, + userId: "user-1", + username: "alice", + grantedByUsername: "admin", + }); + }); + + it("lists shared hosts for direct and role access", async () => { + const repo = await createRepository(); + + const sharedHosts = await repo.listSharedHosts( + "user-1", + [7], + activeAccessTime, + ); + + expect(sharedHosts).toMatchObject([ + { + id: 42, + name: "prod", + ip: "10.0.0.42", + ownerUsername: "owner", + permissionLevel: "view", + }, + { + id: 42, + name: "prod", + ip: "10.0.0.42", + ownerUsername: "owner", + permissionLevel: "view", + }, + ]); + }); + + it("lists visible host access entries for host list access checks", async () => { + const repo = await createRepository(); + + await expect( + repo.listVisibleHostAccessEntries("user-1", [7], activeAccessTime), + ).resolves.toEqual([ + { + hostId: 42, + permissionLevel: "view", + expiresAt: "2026-06-27T00:00:00.000Z", + }, + { + hostId: 42, + permissionLevel: "view", + expiresAt: null, + }, + ]); + }); + + it("lists role host access credential sources for role assignment", async () => { + const repo = await createRepository(); + + await expect(repo.listRoleHostAccessCredentialSources(7)).resolves.toEqual([ + { + hostAccessId: 2, + credentialId: 123, + rdpCredentialId: 124, + vncCredentialId: 125, + telnetCredentialId: 126, + hostId: 42, + hostOwnerId: "owner-1", + }, + ]); + }); + + it("finds shared secrets per protocol and host access owner", async () => { + const repo = await createRepository(); + + await expect( + repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"), + ).resolves.toMatchObject({ + id: 8, + hostAccessId: 2, + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + targetUserId: "user-1", + encryptedUsername: "enc-user", + encryptedAuthType: "enc-auth", + }); + + await expect( + repo.findSharedSecretForHostUserProtocol(42, "user-1", "rdp"), + ).resolves.toMatchObject({ + id: 9, + protocol: "rdp", + sourceType: "inline", + originalCredentialId: null, + }); + + await expect( + repo.findSharedSecretForHostUserProtocol(42, "user-1", "vnc"), + ).resolves.toBeNull(); + await expect( + repo.findSharedSecretForHostUserProtocol(99, "user-1", "ssh"), + ).resolves.toBeNull(); + await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1"); + await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull(); + }); + + it("lists active grants, finds grants by id and updates grant level/expiry", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const grants = await repo.listActiveHostAccessGrants(42, activeAccessTime); + expect(grants.map((grant) => grant.id).sort()).toEqual([1, 2]); + + // Host 44's only grant expired before activeAccessTime. + await expect( + repo.listActiveHostAccessGrants(44, activeAccessTime), + ).resolves.toEqual([]); + + await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({ + id: 1, + hostId: 42, + permissionLevel: "view", + }); + await expect(repo.findHostAccessById(1, 99)).resolves.toBeNull(); + + await expect( + repo.updateHostAccessGrant(1, 42, { + permissionLevel: "manage", + expiresAt: "2026-07-01T00:00:00.000Z", + }), + ).resolves.toBe(true); + await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({ + permissionLevel: "manage", + expiresAt: "2026-07-01T00:00:00.000Z", + }); + + await expect( + repo.updateHostAccessGrant(999, 42, { permissionLevel: "view" }), + ).resolves.toBe(false); + expect(writeCount).toBe(1); + }); + + it("lists shared snippets and preserves route-level direct-over-role behavior", async () => { + const repo = await createRepository(); + + const sharedSnippets = await repo.listSharedSnippets( + "user-1", + [7], + activeAccessTime, + ); + + expect(sharedSnippets).toHaveLength(1); + expect(sharedSnippets[0]).toMatchObject({ + id: 99, + name: "deploy", + ownerUsername: "owner", + permissionLevel: "view", + }); + }); + + it("lists visible shared snippets for the main snippets route", async () => { + const repo = await createRepository(); + + const sharedSnippets = await repo.listVisibleSharedSnippets( + "user-1", + [7], + activeAccessTime, + ); + + expect(sharedSnippets.map((snippet) => snippet.id)).toEqual([99, 99]); + expect(sharedSnippets[0]).toMatchObject({ + userId: "owner-1", + name: "deploy", + content: "echo deploy", + ownerUsername: "owner", + }); + }); + + it("finds an accessible shared snippet for direct or role access", async () => { + const repo = await createRepository(); + + await expect( + repo.findAccessibleSharedSnippet(99, "user-2", [7], activeAccessTime), + ).resolves.toMatchObject({ + id: 99, + userId: "owner-1", + name: "deploy", + content: "echo deploy", + ownerUsername: "owner", + permissionLevel: "view", + hostFilter: null, + }); + + await expect( + repo.findAccessibleSharedSnippet( + 99, + "user-2", + [7], + "2026-06-28T00:00:00.000Z", + ), + ).resolves.toBeNull(); + }); + + it("upserts host access and updates overrides", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.upsertHostAccess({ + hostId: 42, + targetType: "user", + targetUserId: "user-1", + grantedBy: "admin", + permissionLevel: "view", + expiresAt: "2026-06-28T00:00:00.000Z", + }); + + expect(updated).toEqual({ id: 1, created: false }); + expect( + (await repo.listHostAccess(42)).find((row) => row.id === 1), + ).toMatchObject({ + expiresAt: "2026-06-28T00:00:00.000Z", + }); + + const created = await repo.upsertHostAccess({ + hostId: 43, + targetType: "role", + targetRoleId: 7, + grantedBy: "admin", + permissionLevel: "view", + expiresAt: null, + }); + expect(created.created).toBe(true); + + const directAccess = await repo.findDirectHostAccess(42, "user-1"); + expect(directAccess?.id).toBe(1); + + await repo.updateHostAccessOverrideCredential(1, 123); + expect( + (await repo.findDirectHostAccess(42, "user-1"))?.overrideCredentialId, + ).toBe(123); + + await repo.touchHostAccess(1, "2026-06-26T03:00:00.000Z"); + expect( + (await repo.findDirectHostAccess(42, "user-1"))?.lastAccessedAt, + ).toBe("2026-06-26T03:00:00.000Z"); + + await repo.revokeHostAccess(1, 42); + expect(await repo.findDirectHostAccess(42, "user-1")).toBeNull(); + expect(writeCount).toBe(5); + }); + + it("finds active host access and deletes expired host access", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.findActiveHostAccess( + 42, + "user-1", + [7], + "2026-06-26T12:00:00.000Z", + ), + ).toMatchObject({ id: 1 }); + expect( + await repo.findActiveHostAccess( + 44, + "user-1", + [], + "2026-06-26T12:00:00.000Z", + ), + ).toBeNull(); + + expect(await repo.deleteExpiredHostAccess("2026-06-26T12:00:00.000Z")).toBe( + 1, + ); + expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull(); + expect(writeCount).toBe(1); + }); + + it("deletes host access for a host and only saves when rows changed", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteHostAccessForHost(42)).toBe(2); + expect(await repo.listHostAccess(42)).toEqual([]); + expect(writeCount).toBe(1); + + expect(await repo.deleteHostAccessForHost(42)).toBe(0); + expect(writeCount).toBe(1); + }); + + it("deletes host access for multiple hosts", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteHostAccessForHosts([])).toBe(0); + expect(writeCount).toBe(0); + + expect(await repo.deleteHostAccessForHosts([42, 44])).toBe(3); + expect(await repo.listHostAccess(42)).toEqual([]); + expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull(); + expect(writeCount).toBe(1); + }); + + it("deletes host access that references a user directly or as grantor", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(3); + expect(await repo.listHostAccess(42)).toEqual([]); + expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull(); + expect(writeCount).toBe(1); + + expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0); + expect(writeCount).toBe(1); + }); + + it("upserts and revokes snippet access", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.upsertSnippetAccess({ + snippetId: 99, + targetType: "user", + targetUserId: "user-1", + grantedBy: "admin", + expiresAt: "2026-06-28T00:00:00.000Z", + }); + + expect(updated).toEqual({ id: 3, created: false }); + expect( + (await repo.listSnippetAccess(99)).find((row) => row.id === 3), + ).toMatchObject({ expiresAt: "2026-06-28T00:00:00.000Z" }); + + const created = await repo.upsertSnippetAccess({ + snippetId: 100, + targetType: "role", + targetRoleId: 7, + grantedBy: "admin", + expiresAt: null, + }); + expect(created.created).toBe(true); + + await repo.revokeSnippetAccess(3, 99); + expect((await repo.listSnippetAccess(99)).map((row) => row.id)).toEqual([ + 4, + ]); + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/tests/database/repositories/recent-activity-repository.test.ts b/src/backend/tests/database/repositories/recent-activity-repository.test.ts new file mode 100644 index 00000000..52c0d405 --- /dev/null +++ b/src/backend/tests/database/repositories/recent-activity-repository.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js"; + +describe("RecentActivityRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: RecentActivityRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE recent_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + host_id INTEGER NOT NULL, + host_name TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp) + VALUES + (1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'), + (2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'), + (3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z'); + `); + + return { + repository: new RecentActivityRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("lists, creates, and trims recent activity", async () => { + let writeCount = 0; + const { repository, sqlite } = await createRepository(() => { + writeCount += 1; + }); + + expect( + (await repository.listByUserId("user-1", 2)).map((row) => row.id), + ).toEqual([2, 1]); + + const created = await repository.create({ + userId: "user-1", + type: "terminal", + hostId: 1, + hostName: "one", + timestamp: "2026-06-26T00:03:00.000Z", + }); + expect(created).toMatchObject({ + userId: "user-1", + type: "terminal", + hostId: 1, + }); + + expect(await repository.trimUserActivity("user-1", 2)).toBe(1); + expect( + sqlite + .prepare( + "SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC", + ) + .all("user-1"), + ).toEqual([{ id: created.id }, { id: 2 }]); + expect(writeCount).toBe(2); + }); + + it("deletes activity by user id and only triggers writes for changed rows", async () => { + let writeCount = 0; + const { repository: repo } = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(0); + + expect(await repo.deleteByUserId("user-1")).toBe(2); + expect(writeCount).toBe(1); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(writeCount).toBe(1); + }); + + it("deletes activity by host id and host id list", async () => { + let writeCount = 0; + const { repository: repo } = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteByHostId(1)).toBe(1); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2, 3])).toBe(2); + expect(writeCount).toBe(2); + }); +}); diff --git a/src/backend/tests/database/repositories/role-repository.test.ts b/src/backend/tests/database/repositories/role-repository.test.ts new file mode 100644 index 00000000..585f9379 --- /dev/null +++ b/src/backend/tests/database/repositories/role-repository.test.ts @@ -0,0 +1,278 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { RoleRepository } from "../../../database/repositories/role-repository.js"; + +describe("RoleRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + permissions TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + role_id INTEGER NOT NULL, + granted_by TEXT, + granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash, is_admin, is_oidc) + VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0); + `); + + return new RoleRepository(context, onWrite); + } + + it("creates, lists, updates, and finds roles", async () => { + const repo = await createRepository(); + + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + description: "Ops access", + isSystem: false, + permissions: null, + }); + + expect((await repo.findRoleByName("ops"))?.id).toBe(roleId); + expect((await repo.findRoleById(roleId))?.displayName).toBe("Operations"); + + await repo.updateRole(roleId, { + displayName: "Ops", + description: null, + updatedAt: "2026-06-26T00:00:00.000Z", + }); + + const roles = await repo.listRoles(); + expect(roles.map((role) => role.name)).toEqual(["ops"]); + expect(roles[0].displayName).toBe("Ops"); + expect(roles[0].description).toBeNull(); + }); + + it("assigns, lists, and removes user roles", async () => { + const repo = await createRepository(); + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: JSON.stringify(["hosts.read", "hosts.*"]), + }); + + await repo.assignRoleToUser({ + userId: "user-1", + roleId, + grantedBy: "admin", + }); + + expect(await repo.findUserRole("user-1", roleId)).not.toBeNull(); + expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]); + expect(await repo.listRoleUserIds(roleId)).toEqual(["user-1"]); + expect((await repo.listUserRoles("user-1"))[0]).toMatchObject({ + roleId, + roleName: "ops", + roleDisplayName: "Operations", + isSystem: false, + }); + expect(await repo.listUserRolePermissions("user-1")).toEqual([ + { permissions: JSON.stringify(["hosts.read", "hosts.*"]) }, + ]); + expect(await repo.userHasAnyRoleName("user-1", ["admin", "ops"])).toBe( + true, + ); + expect(await repo.userHasAnyRoleName("user-1", ["admin"])).toBe(false); + expect(await repo.userHasAnyRoleName("user-1", [])).toBe(false); + + await repo.removeRoleFromUser("user-1", roleId); + expect(await repo.findUserRole("user-1", roleId)).toBeNull(); + }); + + it("assigns roles by name", async () => { + const repo = await createRepository(); + const roleId = await repo.createRole({ + name: "user", + displayName: "User", + isSystem: true, + permissions: null, + }); + + expect( + await repo.assignRoleNameToUser({ + userId: "user-1", + roleName: "missing", + grantedBy: "admin", + }), + ).toBe(false); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + + expect( + await repo.assignRoleNameToUser({ + userId: "user-1", + roleName: "user", + grantedBy: "admin", + }), + ).toBe(true); + expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]); + }); + + it("switches user roles by role name", async () => { + const repo = await createRepository(); + const userRoleId = await repo.createRole({ + name: "user", + displayName: "User", + isSystem: true, + permissions: null, + }); + const adminRoleId = await repo.createRole({ + name: "admin", + displayName: "Admin", + isSystem: true, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId: userRoleId, + grantedBy: "admin", + }); + + await expect( + repo.switchUserRoleName({ + userId: "user-1", + addRoleName: "admin", + removeRoleName: "user", + grantedBy: "admin", + }), + ).resolves.toEqual({ added: true, removed: true }); + expect(await repo.listUserRoleIds("user-1")).toEqual([adminRoleId]); + + await expect( + repo.switchUserRoleName({ + userId: "user-1", + addRoleName: "missing", + removeRoleName: "admin", + grantedBy: "admin", + }), + ).resolves.toEqual({ added: false, removed: true }); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + }); + + it("deletes role assignments and returns affected users", async () => { + const repo = await createRepository(); + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId, + grantedBy: "admin", + }); + + const result = await repo.deleteRole(roleId); + + expect(result.deletedUserIds).toEqual(["user-1"]); + expect(await repo.findRoleById(roleId)).toBeNull(); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + }); + + it("removes all roles for a user only when assignments exist", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + const opsRoleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: null, + }); + const auditRoleId = await repo.createRole({ + name: "audit", + displayName: "Audit", + isSystem: false, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId: opsRoleId, + grantedBy: "admin", + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId: auditRoleId, + grantedBy: "admin", + }); + + expect(await repo.removeAllRolesFromUser("missing-user")).toBe(0); + expect(writeCount).toBe(4); + + expect(await repo.removeAllRolesFromUser("user-1")).toBe(2); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + expect(writeCount).toBe(5); + }); + + it("runs the write hook after writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId, + grantedBy: "admin", + }); + await repo.updateRole(roleId, { displayName: "Ops" }); + await repo.removeRoleFromUser("user-1", roleId); + await repo.deleteRole(roleId); + + expect(writeCount).toBe(5); + }); +}); diff --git a/src/backend/tests/database/repositories/session-recording-repository.test.ts b/src/backend/tests/database/repositories/session-recording-repository.test.ts new file mode 100644 index 00000000..7d2437cf --- /dev/null +++ b/src/backend/tests/database/repositories/session-recording-repository.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SessionRecordingRepository } from "../../../database/repositories/session-recording-repository.js"; + +describe("SessionRecordingRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + ip TEXT + ); + + CREATE TABLE session_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + access_id INTEGER, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TEXT, + duration INTEGER, + commands TEXT, + dangerous_actions TEXT, + recording_path TEXT, + protocol TEXT NOT NULL DEFAULT 'ssh', + format TEXT NOT NULL DEFAULT 'text', + terminated_by_owner INTEGER DEFAULT 0, + termination_reason TEXT + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip) + VALUES (1, 'user-1', 'one', '10.0.0.1'), (2, 'user-1', 'two', '10.0.0.2'), (3, 'user-2', 'other', '10.0.0.3'); + `); + + return new SessionRecordingRepository(context, onWrite); + } + + it("creates and lists session recordings with host metadata", async () => { + const repo = await createRepository(); + + const first = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-06-27T00:00:00.000Z", + endedAt: "2026-06-27T00:01:00.000Z", + duration: 60, + recordingPath: "/tmp/one.log", + }); + await repo.create({ + userId: "user-1", + hostId: 2, + startedAt: "2026-06-27T00:02:00.000Z", + recordingPath: "/tmp/two.log", + }); + await repo.create({ + userId: "user-2", + hostId: 3, + startedAt: "2026-06-27T00:03:00.000Z", + }); + + expect(first).toMatchObject({ + userId: "user-1", + hostId: 1, + duration: 60, + recordingPath: "/tmp/one.log", + }); + + const rows = await repo.listByUserIdWithHost("user-1"); + expect(rows.map((row) => row.hostName)).toEqual(["two", "one"]); + expect(rows[0]).toMatchObject({ + hostIp: "10.0.0.2", + recordingPath: "/tmp/two.log", + protocol: "ssh", + format: "text", + }); + }); + + it("finds paths and prunes old recordings", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const old = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-01-01T00:00:00.000Z", + recordingPath: "/tmp/old.log", + }); + const current = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-06-27T00:00:00.000Z", + recordingPath: "/tmp/current.log", + }); + expect(writeCount).toBe(2); + + expect(await repo.findPathByIdForUser("user-2", old.id)).toBeNull(); + expect(await repo.findPathByIdForUser("user-1", old.id)).toMatchObject({ + recordingPath: "/tmp/old.log", + }); + expect(await repo.listPathsOlderThan("2026-02-01T00:00:00.000Z")).toEqual([ + { id: old.id, recordingPath: "/tmp/old.log" }, + ]); + + expect(await repo.deleteById(old.id)).toBe(true); + expect(await repo.deleteById(old.id)).toBe(false); + expect(await repo.findByIdForUser("user-1", current.id)).toMatchObject({ + id: current.id, + }); + expect(writeCount).toBe(3); + }); + + it("deletes recordings by user and host references", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const first = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-06-27T00:00:00.000Z", + }); + await repo.create({ + userId: "user-1", + hostId: 2, + startedAt: "2026-06-27T00:01:00.000Z", + }); + await repo.create({ + userId: "user-2", + hostId: 3, + startedAt: "2026-06-27T00:02:00.000Z", + }); + expect(writeCount).toBe(3); + + expect(await repo.deleteForUser("user-2", first.id)).toBe(false); + expect(await repo.deleteForUser("user-1", first.id)).toBe(true); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2])).toBe(1); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByHostId(3)).toBe(0); + expect(writeCount).toBe(6); + }); +}); diff --git a/src/backend/tests/database/repositories/settings-repository.test.ts b/src/backend/tests/database/repositories/settings-repository.test.ts new file mode 100644 index 00000000..94e1ca9a --- /dev/null +++ b/src/backend/tests/database/repositories/settings-repository.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SettingsRepository } from "../../../database/repositories/settings-repository.js"; + +describe("SettingsRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + return new SettingsRepository(context); + } + + it("creates and updates settings", async () => { + const repo = await createRepository(); + + await repo.set("allow_registration", "true"); + expect(await repo.get("allow_registration")).toBe("true"); + + await repo.set("allow_registration", "false"); + expect(await repo.get("allow_registration")).toBe("false"); + }); + + it("lists and upserts settings", async () => { + const repo = await createRepository(); + + await repo.upsert("theme", "dark"); + await repo.upsert("allow_registration", "true"); + await repo.upsert("theme", "light"); + + expect(await repo.get("theme")).toBe("light"); + expect(await repo.listAll()).toEqual( + expect.arrayContaining([ + { key: "theme", value: "light" }, + { key: "allow_registration", value: "true" }, + ]), + ); + }); + + it("returns null or fallback when setting is missing", async () => { + const repo = await createRepository(); + + expect(await repo.get("missing")).toBeNull(); + expect(await repo.getBoolean("missing", true)).toBe(true); + }); + + it("reads boolean settings", async () => { + const repo = await createRepository(); + + await repo.set("enabled", "1"); + await repo.set("disabled", "false"); + + expect(await repo.getBoolean("enabled")).toBe(true); + expect(await repo.getBoolean("disabled", true)).toBe(false); + }); + + it("deletes settings", async () => { + const repo = await createRepository(); + + await repo.set("theme", "dark"); + await repo.delete("theme"); + + expect(await repo.get("theme")).toBeNull(); + }); + + it("deletes settings by SQL LIKE pattern", async () => { + const repo = await createRepository(); + + await repo.set("user_kek_salt_user-1", "salt"); + await repo.set("user_encrypted_dek_user-1", "dek"); + await repo.set("user_kek_salt_user-2", "other"); + + expect(await repo.deleteLike("user_%_user-1")).toBe(2); + expect(await repo.get("user_kek_salt_user-1")).toBeNull(); + expect(await repo.get("user_encrypted_dek_user-1")).toBeNull(); + expect(await repo.get("user_kek_salt_user-2")).toBe("other"); + }); +}); diff --git a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts new file mode 100644 index 00000000..75acf718 --- /dev/null +++ b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SharedHostSecretsRepository } from "../../../database/repositories/shared-host-secrets-repository.js"; + +describe("SharedHostSecretsRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: SharedHostSecretsRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite!.exec(` + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'connect', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + credential_id INTEGER, + rdp_credential_id INTEGER, + vnc_credential_id INTEGER, + telnet_credential_id INTEGER + ); + + CREATE TABLE shared_host_secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_access_id INTEGER NOT NULL, + target_user_id 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, + UNIQUE(host_access_id, target_user_id, protocol) + ); + + INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id) + VALUES + (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124), + (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL), + (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL); + + INSERT INTO host_access (id, host_id, user_id, role_id, granted_by) + VALUES + (1, 42, 'user-1', NULL, 'owner-1'), + (2, 42, NULL, 7, 'owner-1'), + (3, 43, 'user-2', NULL, 'owner-1'); + `); + + return { + repository: new SharedHostSecretsRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("upserts snapshots per protocol and finds them by host/user/protocol", async () => { + let writeCount = 0; + const { repository } = await createRepository(() => { + writeCount += 1; + }); + + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + encryptedUsername: "enc-user", + encryptedAuthType: "password", + encryptedPassword: "enc-pass", + }); + + await expect( + repository.findForHostUserProtocol(42, "user-1", "ssh"), + ).resolves.toMatchObject({ + hostAccessId: 1, + protocol: "ssh", + encryptedPassword: "enc-pass", + }); + + // Upsert on the same (grant, user, protocol) updates in place. + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "inline", + originalCredentialId: null, + encryptedUsername: "enc-user-2", + encryptedAuthType: "key", + encryptedKey: "enc-key", + }); + + const updated = await repository.findForHostUserProtocol( + 42, + "user-1", + "ssh", + ); + expect(updated).toMatchObject({ + sourceType: "inline", + encryptedUsername: "enc-user-2", + encryptedKey: "enc-key", + }); + + await expect( + repository.findForHostUserProtocol(42, "user-1", "rdp"), + ).resolves.toBeNull(); + await expect( + repository.findForHostUserProtocol(43, "user-1", "ssh"), + ).resolves.toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes stale protocols while keeping the listed ones", async () => { + const { repository } = await createRepository(); + + for (const protocol of ["ssh", "rdp", "vnc"] as const) { + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol, + sourceType: "inline", + encryptedAuthType: "direct", + }); + } + + await repository.deleteForHostAccessAndTarget(1, "user-1", ["ssh"]); + + await expect( + repository.findForHostUserProtocol(42, "user-1", "ssh"), + ).resolves.not.toBeNull(); + await expect( + repository.findForHostUserProtocol(42, "user-1", "rdp"), + ).resolves.toBeNull(); + await expect( + repository.findForHostUserProtocol(42, "user-1", "vnc"), + ).resolves.toBeNull(); + }); + + it("deletes by host access, target user, credential and role membership", async () => { + const { repository } = await createRepository(); + + await repository.upsert({ + hostAccessId: 1, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + encryptedAuthType: "password", + }); + await repository.upsert({ + hostAccessId: 2, + targetUserId: "user-1", + protocol: "ssh", + sourceType: "credential", + originalCredentialId: 123, + encryptedAuthType: "password", + }); + await repository.upsert({ + hostAccessId: 3, + targetUserId: "user-2", + protocol: "ssh", + sourceType: "inline", + encryptedAuthType: "password", + }); + + // Role-membership cleanup: only grant 2 targets role 7. + expect(await repository.deleteForRoleMember(7, "user-1")).toBe(1); + await expect( + repository.existsForHostAccessAndTargetUser(2, "user-1"), + ).resolves.toBe(false); + await expect( + repository.existsForHostAccessAndTargetUser(1, "user-1"), + ).resolves.toBe(true); + + expect(await repository.deleteByHostAccessId(1)).toBe(1); + expect(await repository.deleteByTargetUserId("user-2")).toBe(1); + expect(await repository.deleteByOriginalCredentialId(123)).toBe(0); + }); + + it("finds host ids referencing a credential for the owner", async () => { + const { repository } = await createRepository(); + + await expect( + repository.findHostIdsReferencingCredential("owner-1", 123), + ).resolves.toEqual([42]); + await expect( + repository.findHostIdsReferencingCredential("owner-1", 124), + ).resolves.toEqual([42]); + await expect( + repository.findHostIdsReferencingCredential("owner-1", 999), + ).resolves.toEqual([]); + await expect( + repository.findHostIdsReferencingCredential("owner-2", 123), + ).resolves.toEqual([44]); + }); +}); diff --git a/src/backend/tests/database/repositories/snippet-repository.test.ts b/src/backend/tests/database/repositories/snippet-repository.test.ts new file mode 100644 index 00000000..8dc1d4cf --- /dev/null +++ b/src/backend/tests/database/repositories/snippet-repository.test.ts @@ -0,0 +1,379 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SnippetRepository } from "../../../database/repositories/snippet-repository.js"; + +describe("SnippetRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite?: () => void): Promise<{ + repository: SnippetRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + folder TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + host_filter TEXT + ); + + CREATE TABLE snippet_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO snippets ( + id, user_id, name, content, description, folder, "order", host_filter + ) + VALUES + (1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL), + (2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'), + (3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL); + + INSERT INTO snippet_folders (id, user_id, name, color, icon) + VALUES + (1, 'user-1', 'ops', '#123456', 'terminal'), + (2, 'user-1', 'db', NULL, NULL), + (3, 'user-2', 'other', NULL, NULL); + `); + + return { + repository: new SnippetRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("finds owned snippets only", async () => { + const { repository } = await createRepository(); + + await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({ + id: 1, + userId: "user-1", + name: "root", + }); + await expect(repository.findOwnedById("user-1", 3)).resolves.toBeNull(); + await expect(repository.findOwnedById("user-1", 999)).resolves.toBeNull(); + }); + + it("lists folders by name for a user", async () => { + const { repository } = await createRepository(); + + const rows = await repository.listFolders("user-1"); + + expect(rows.map((row) => row.name)).toEqual(["db", "ops"]); + }); + + it("lists export data for a user", async () => { + const { repository } = await createRepository(); + + const snippets = await repository.listSnippetsForExport("user-1"); + const folders = await repository.listFoldersForExport("user-1"); + + expect(snippets.map((row) => row.name)).toEqual(["root", "deploy"]); + expect(folders.map((row) => row.name)).toEqual(["db", "ops"]); + }); + + it("lists owned snippets for route merging", async () => { + const { repository } = await createRepository(); + + const rows = await repository.listOwnedSnippets("user-1"); + + expect(rows.map((row) => row.name)).toEqual(["root", "deploy"]); + }); + + it("reorders snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + await repository.reorderSnippets("user-1", [ + { id: 1, order: 9, folder: " ops " }, + { id: 999, order: 1 }, + ]); + + await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({ + order: 9, + folder: "ops", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("creates snippets with the next folder order", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const created = await repository.createSnippet("user-1", { + name: " new ", + content: " echo ok ", + description: " desc ", + folder: "ops", + hostFilter: { os: "linux" }, + }); + + expect(created).toMatchObject({ + userId: "user-1", + name: "new", + content: "echo ok", + description: "desc", + folder: "ops", + order: 2, + hostFilter: JSON.stringify({ os: "linux" }), + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("updates snippets and returns the original row for audit names", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const result = await repository.updateSnippet("user-1", 2, { + name: " deploy new ", + content: " make deploy2 ", + description: null, + folder: null, + order: 7, + hostFilter: null, + }); + const missing = await repository.updateSnippet("user-1", 3, { + name: "nope", + }); + + expect(result?.existing).toMatchObject({ name: "deploy" }); + expect(result?.updated).toMatchObject({ + name: "deploy new", + content: "make deploy2", + description: null, + folder: null, + order: 7, + hostFilter: null, + }); + expect(missing).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes snippets and returns the deleted row for audit names", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const deleted = await repository.deleteSnippet("user-1", 2); + const missing = await repository.deleteSnippet("user-1", 3); + + expect(deleted).toMatchObject({ id: 2, name: "deploy" }); + expect(missing).toBeNull(); + await expect(repository.findOwnedById("user-1", 2)).resolves.toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes all snippets and folders for a user", async () => { + const onWrite = vi.fn(); + const { repository, sqlite } = await createRepository(onWrite); + + await expect(repository.deleteByUserId("user-1")).resolves.toEqual({ + snippetsDeleted: 2, + foldersDeleted: 2, + }); + + expect(sqlite.prepare("SELECT id FROM snippets ORDER BY id").all()).toEqual( + [{ id: 3 }], + ); + expect( + sqlite.prepare("SELECT id FROM snippet_folders ORDER BY id").all(), + ).toEqual([{ id: 3 }]); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("bulk imports folders and snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const result = await repository.bulkImport( + "user-1", + [ + { + name: " new snippet ", + content: " echo hi ", + description: " desc ", + folder: " new folder ", + hostFilter: "linux", + }, + { name: "", content: "bad" }, + { name: "deploy", content: "skip", folder: "ops" }, + ], + [ + { name: " new folder ", color: " #fff ", icon: " star " }, + { name: "ops" }, + { name: "" }, + ], + false, + ); + + expect(result).toEqual({ + snippetsImported: 1, + snippetsSkipped: 1, + snippetsUpdated: 0, + foldersImported: 1, + foldersSkipped: 1, + failed: 2, + errors: [ + "Folder missing name", + "Snippet 2: name and content are required", + ], + }); + await expect(repository.findOwnedById("user-1", 4)).resolves.toMatchObject({ + name: "new snippet", + content: "echo hi", + description: "desc", + folder: "new folder", + order: 0, + hostFilter: "linux", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("bulk import overwrites existing snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const result = await repository.bulkImport( + "user-1", + [ + { + name: "deploy", + content: "make deploy v2", + folder: "ops", + order: 5, + hostFilter: "prod", + }, + ], + undefined, + true, + ); + + expect(result).toMatchObject({ + snippetsImported: 0, + snippetsSkipped: 0, + snippetsUpdated: 1, + failed: 0, + }); + await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({ + content: "make deploy v2", + order: 5, + hostFilter: "prod", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("creates folders and rejects duplicate names", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const created = await repository.createFolder( + "user-1", + " new ", + " #fff ", + " star ", + ); + const duplicate = await repository.createFolder( + "user-1", + "ops", + null, + null, + ); + + expect(created).toMatchObject({ + userId: "user-1", + name: "new", + color: "#fff", + icon: "star", + }); + expect(duplicate).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("updates folder metadata", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const updated = await repository.updateFolderMetadata( + "user-1", + "ops", + " #abc ", + undefined, + ); + const missing = await repository.updateFolderMetadata( + "user-1", + "missing", + null, + null, + ); + + expect(updated).toMatchObject({ + name: "ops", + color: "#abc", + icon: "terminal", + }); + expect(missing).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("renames folders and attached snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + await expect( + repository.renameFolder("user-1", "missing", "new"), + ).resolves.toEqual({ status: "missing" }); + await expect( + repository.renameFolder("user-1", "ops", "db"), + ).resolves.toEqual({ + status: "conflict", + }); + await expect( + repository.renameFolder("user-1", "ops", "deploys"), + ).resolves.toEqual({ status: "renamed" }); + + await expect(repository.listFolders("user-1")).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "deploys" })]), + ); + await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({ + folder: "deploys", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes folders and moves snippets to the root", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + await repository.deleteFolder("user-1", "ops"); + + expect( + (await repository.listFolders("user-1")).map((row) => row.name), + ).toEqual(["db"]); + await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({ + folder: null, + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts new file mode 100644 index 00000000..21b25aa0 --- /dev/null +++ b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SshCredentialUsageRepository } from "../../../database/repositories/ssh-credential-usage-repository.js"; + +describe("SshCredentialUsageRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE ssh_credential_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_credentials (id, user_id, name) + VALUES (1, 'user-1', 'cred-one'), (2, 'user-2', 'cred-two'); + `); + + return new SshCredentialUsageRepository(context, onWrite); + } + + it("creates usage records", async () => { + const repo = await createRepository(); + + const created = await repo.create(1, 1, "user-1"); + + expect(created).toMatchObject({ + credentialId: 1, + hostId: 1, + userId: "user-1", + }); + }); + + it("lists usage records by user", async () => { + const repo = await createRepository(); + + await repo.create(1, 1, "user-1"); + await repo.create(1, 2, "user-1"); + await repo.create(2, 3, "user-2"); + + expect( + (await repo.listByUserId("user-1")).map((row) => row.hostId), + ).toEqual([1, 2]); + }); + + it("deletes usage records by user, host, and host list", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create(1, 1, "user-1"); + await repo.create(1, 2, "user-1"); + await repo.create(2, 3, "user-2"); + expect(writeCount).toBe(3); + + expect(await repo.deleteByHostId(1)).toBe(1); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2])).toBe(1); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByUserId("user-2")).toBe(0); + expect(writeCount).toBe(6); + }); +}); diff --git a/src/backend/tests/database/repositories/sso-provider-repository.test.ts b/src/backend/tests/database/repositories/sso-provider-repository.test.ts new file mode 100644 index 00000000..ec0d7cd0 --- /dev/null +++ b/src/backend/tests/database/repositories/sso-provider-repository.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SsoProviderRepository } from "../../../database/repositories/sso-provider-repository.js"; + +describe("SsoProviderRepository", () => { + let adapter: TestSqliteDatabase | null = null; + let sqlite: Awaited>["sqlite"]; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + sqlite = undefined; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + sqlite = context.sqlite; + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0, + sso_provider_id INTEGER + ); + + CREATE TABLE sso_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + display_order INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + + return new SsoProviderRepository(context, onWrite); + } + + it("creates, lists, finds, updates, and deletes providers", async () => { + const repo = await createRepository(); + + const disabled = await repo.create({ + name: "Disabled", + type: "oidc", + enabled: false, + displayOrder: 1, + config: "{}", + }); + const enabled = await repo.create({ + name: "GitHub", + type: "github", + enabled: true, + displayOrder: 0, + config: '{"client_id":"id"}', + }); + + expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([ + enabled.id, + ]); + expect((await repo.listAll()).map((row) => row.id)).toEqual([ + enabled.id, + disabled.id, + ]); + expect((await repo.findById(enabled.id))?.name).toBe("GitHub"); + expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id); + + const updated = await repo.update(enabled.id, { + name: "GitHub SSO", + config: '{"client_id":"updated"}', + updatedAt: "2026-06-27T00:00:00.000Z", + }); + expect(updated?.name).toBe("GitHub SSO"); + expect(updated?.config).toContain("updated"); + + expect(await repo.delete(enabled.id)).toBe(true); + expect(await repo.findById(enabled.id)).toBeNull(); + expect(await repo.delete(enabled.id)).toBe(false); + }); + + it("counts users associated with a provider", async () => { + const repo = await createRepository(); + const provider = await repo.create({ + name: "LDAP", + type: "ldap", + enabled: true, + displayOrder: 0, + config: "{}", + }); + + sqlite?.exec(` + INSERT INTO users (id, username, password_hash, sso_provider_id) + VALUES ('user-1', 'u1', 'hash', ${provider.id}), + ('user-2', 'u2', 'hash', ${provider.id}), + ('user-3', 'u3', 'hash', NULL); + `); + + expect(await repo.countUsersByProviderId(provider.id)).toBe(2); + }); + + it("runs the write hook after provider writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const provider = await repo.create({ + name: "OIDC", + type: "oidc", + enabled: true, + displayOrder: 0, + config: "{}", + }); + await repo.update(provider.id, { enabled: false }); + await repo.delete(provider.id); + await repo.delete(provider.id); + + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts new file mode 100644 index 00000000..26d2119d --- /dev/null +++ b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { DataCrypto } from "../../../utils/data-crypto.js"; +import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js"; + +describe("TermixIdentityCaRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + vi.restoreAllMocks(); + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite = vi.fn()): Promise<{ + repo: TermixIdentityCaRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + onWrite: ReturnType; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE termix_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + handle TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE termix_identity_ca ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id INTEGER NOT NULL UNIQUE, + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + validity_days INTEGER NOT NULL DEFAULT 90, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'); + INSERT INTO termix_identities (id, user_id, handle) + VALUES (7, 'user-1', 'alice'); + `); + + return { + repo: new TermixIdentityCaRepository(context, onWrite), + sqlite: context.sqlite!, + onWrite, + }; + } + + function mockCrypto(): void { + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "encryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + privateKey: "encrypted-ca-private", + }) as typeof record, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + privateKey: + record.privateKey === "encrypted-ca-private" + ? "decrypted-ca-private" + : record.privateKey, + }) as typeof record, + ); + } + + it("creates CA private keys with the real row id before encryption", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + mockCrypto(); + + const created = await repo.createEncryptedForUser("user-1", { + identityId: 7, + userId: "user-1", + publicKey: "ssh-ed25519 public", + privateKey: "plain-ca-private", + validityDays: 120, + }); + + const raw = sqlite + .prepare( + "SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", + ) + .get(7) as { + id: number; + public_key: string; + private_key: string; + validity_days: number; + }; + + expect(created.privateKey).toBe("decrypted-ca-private"); + expect(raw.private_key).toBe("encrypted-ca-private"); + expect(raw.public_key).toBe("ssh-ed25519 public"); + expect(raw.validity_days).toBe(120); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "termix_identity_ca", + { id: raw.id, privateKey: "plain-ca-private" }, + "user-1", + Buffer.from("user-key"), + ); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("reads public CA metadata without decrypting private key material", async () => { + const { repo, sqlite } = await createRepository(); + const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord"); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + + await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({ + publicKey: "ssh-ed25519 public", + validityDays: 45, + }); + expect(decryptSpy).not.toHaveBeenCalled(); + }); + + it("decrypts CA private keys through the user data boundary", async () => { + const { repo, sqlite } = await createRepository(); + mockCrypto(); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + + const ca = await repo.findDecryptedByIdentityId("user-1", 7); + + expect(ca).toMatchObject({ + identityId: 7, + publicKey: "ssh-ed25519 public", + privateKey: "decrypted-ca-private", + validityDays: 45, + }); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "termix_identity_ca", + expect.objectContaining({ identityId: 7 }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("updates CA private keys through encrypted writes", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + mockCrypto(); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 old", "encrypted-ca-private", 45); + onWrite.mockClear(); + + const updated = await repo.updateEncryptedForIdentity("user-1", 7, { + publicKey: "ssh-ed25519 new", + privateKey: "plain-updated-ca-private", + validityDays: 90, + }); + + const raw = sqlite + .prepare( + "SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", + ) + .get(7) as { + public_key: string; + private_key: string; + validity_days: number; + }; + + expect(updated).toMatchObject({ + publicKey: "ssh-ed25519 new", + privateKey: "decrypted-ca-private", + validityDays: 90, + }); + expect(raw).toEqual({ + public_key: "ssh-ed25519 new", + private_key: "encrypted-ca-private", + validity_days: 90, + }); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "termix_identity_ca", + expect.objectContaining({ + privateKey: "plain-updated-ca-private", + }), + "user-1", + Buffer.from("user-key"), + ); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes CA rows through the write boundary", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + onWrite.mockClear(); + + await expect(repo.deleteByIdentityId(7)).resolves.toBe(true); + await expect(repo.deleteByIdentityId(7)).resolves.toBe(false); + expect( + sqlite.prepare("SELECT COUNT(*) AS count FROM termix_identity_ca").get(), + ).toEqual({ count: 0 }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes CA rows for a user", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + sqlite + .prepare( + "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)", + ) + .run("user-2", "bob", "hash"); + sqlite + .prepare( + "INSERT INTO termix_identities (id, user_id, handle) VALUES (?, ?, ?)", + ) + .run(8, "user-2", "bob"); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(8, "user-2", "ssh-ed25519 other", "encrypted-other", 90); + onWrite.mockClear(); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect( + sqlite + .prepare( + "SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id", + ) + .all(), + ).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]); + expect(onWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/backend/tests/database/repositories/termix-identity-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-repository.test.ts new file mode 100644 index 00000000..b9c2ad21 --- /dev/null +++ b/src/backend/tests/database/repositories/termix-identity-repository.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { TermixIdentityRepository } from "../../../database/repositories/termix-identity-repository.js"; + +describe("TermixIdentityRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite = vi.fn()): Promise<{ + repo: TermixIdentityRepository; + onWrite: ReturnType; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE termix_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + handle TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE termix_identity_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + key_type TEXT NOT NULL, + algorithm TEXT NOT NULL, + label TEXT, + comment TEXT, + source TEXT NOT NULL DEFAULT 'manual', + credential_id INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return { + repo: new TermixIdentityRepository(context, onWrite), + onWrite, + }; + } + + it("creates, updates, finds, and deletes identities", async () => { + const { repo, onWrite } = await createRepository(); + + const created = await repo.createIdentity({ + userId: "user-1", + handle: "alice", + description: "workstation keys", + }); + + expect(created.id).toBeGreaterThan(0); + expect(await repo.isHandleTaken("alice")).toBe(true); + expect(await repo.findIdentityForUser("user-1")).toMatchObject({ + handle: "alice", + }); + expect(await repo.findIdentityByHandle("alice")).toMatchObject({ + userId: "user-1", + }); + + const updated = await repo.updateIdentityForUser("user-1", { + handle: "alice-renamed", + description: null, + }); + expect(updated).toMatchObject({ + handle: "alice-renamed", + description: null, + }); + + await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(true); + await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(false); + await expect(repo.findIdentityForUser("user-1")).resolves.toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(3); + }); + + it("creates, lists, updates, deletes, and links public keys", async () => { + const { repo, onWrite } = await createRepository(); + const identity = await repo.createIdentity({ + userId: "user-1", + handle: "alice", + description: null, + }); + onWrite.mockClear(); + + const first = await repo.createKey({ + identityId: identity.id, + userId: "user-1", + publicKey: "ssh-ed25519 AAAA1", + keyType: "ssh-ed25519", + algorithm: "ED25519", + label: "laptop", + comment: null, + source: "credential", + credentialId: 10, + }); + await repo.createKey({ + identityId: identity.id, + userId: "user-1", + publicKey: "ssh-rsa AAAA2", + keyType: "ssh-rsa", + algorithm: "RSA", + label: "disabled", + comment: null, + source: "manual", + credentialId: 20, + enabled: false, + }); + + expect(await repo.listKeysByIdentityId(identity.id)).toHaveLength(2); + expect(await repo.listEnabledKeysByIdentityId(identity.id)).toMatchObject([ + { id: first.id, publicKey: "ssh-ed25519 AAAA1" }, + ]); + expect(await repo.listLinkedCredentialIds(identity.id)).toEqual([10]); + + const updated = await repo.updateKeyForUser("user-1", first.id, { + enabled: false, + label: "revoked", + }); + expect(updated).toMatchObject({ enabled: false, label: "revoked" }); + await expect( + repo.findKeyForUser("user-1", first.id), + ).resolves.toMatchObject({ label: "revoked" }); + + await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(true); + await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe( + false, + ); + expect(onWrite).toHaveBeenCalledTimes(4); + }); + + it("deletes identities and keys for a user", async () => { + const { repo, onWrite } = await createRepository(); + const userIdentity = await repo.createIdentity({ + userId: "user-1", + handle: "alice", + description: null, + }); + const otherIdentity = await repo.createIdentity({ + userId: "user-2", + handle: "bob", + description: null, + }); + await repo.createKey({ + identityId: userIdentity.id, + userId: "user-1", + publicKey: "ssh-ed25519 AAAA1", + keyType: "ssh-ed25519", + algorithm: "ED25519", + source: "manual", + }); + await repo.createKey({ + identityId: otherIdentity.id, + userId: "user-2", + publicKey: "ssh-ed25519 AAAA2", + keyType: "ssh-ed25519", + algorithm: "ED25519", + source: "manual", + }); + onWrite.mockClear(); + + await expect(repo.deleteByUserId("user-1")).resolves.toEqual({ + identitiesDeleted: 1, + keysDeleted: 1, + }); + await expect(repo.deleteByUserId("missing")).resolves.toEqual({ + identitiesDeleted: 0, + keysDeleted: 0, + }); + + expect(await repo.findIdentityForUser("user-1")).toBeNull(); + expect(await repo.listKeysByIdentityId(userIdentity.id)).toEqual([]); + expect(await repo.findIdentityForUser("user-2")).toMatchObject({ + handle: "bob", + }); + expect(await repo.listKeysByIdentityId(otherIdentity.id)).toHaveLength(1); + expect(onWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/backend/tests/database/repositories/test-support.ts b/src/backend/tests/database/repositories/test-support.ts new file mode 100644 index 00000000..6aab29fd --- /dev/null +++ b/src/backend/tests/database/repositories/test-support.ts @@ -0,0 +1,31 @@ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import * as schema from "../../../database/db/schema.js"; +import type { DatabaseContext } from "../../../database/repositories/database-context.js"; + +export class TestSqliteDatabase { + private sqlite: Database.Database | null = null; + private context: DatabaseContext | null = null; + + async connect(): Promise { + if (this.context) return this.context; + + this.sqlite = new Database(":memory:"); + this.sqlite.exec("PRAGMA foreign_keys = ON"); + this.context = { + dialect: "sqlite", + drizzle: drizzle(this.sqlite, { schema }), + sqlite: this.sqlite, + }; + + return this.context; + } + + async close(): Promise { + if (this.sqlite) { + this.sqlite.close(); + this.sqlite = null; + this.context = null; + } + } +} diff --git a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts new file mode 100644 index 00000000..a866b54d --- /dev/null +++ b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { TmuxSessionTagRepository } from "../../../database/repositories/tmux-session-tag-repository.js"; + +describe("TmuxSessionTagRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE tmux_session_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + session_name TEXT NOT NULL, + tag TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag) + VALUES + ('user-1', 1, 'api', 'prod'), + ('user-1', 1, 'api', 'critical'), + ('user-1', 1, 'worker', 'batch'), + ('user-2', 2, 'api', 'other'); + `); + + return new TmuxSessionTagRepository(context, onWrite); + } + + it("groups tags by session for a user and host", async () => { + const repo = await createRepository(); + + const tags = await repo.listByUserAndHost("user-1", 1); + + expect(tags.get("api")).toEqual(["prod", "critical"]); + expect(tags.get("worker")).toEqual(["batch"]); + expect(tags.has("missing")).toBe(false); + }); + + it("renames and deletes session tags by host/session", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.renameSessionForHost(1, "api", "api-renamed")).toBe(2); + expect(await repo.renameSessionForHost(1, "missing", "noop")).toBe(0); + + const renamed = await repo.listByUserAndHost("user-1", 1); + expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]); + expect(renamed.has("api")).toBe(false); + + expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(2); + expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(0); + expect(writeCount).toBe(2); + }); + + it("replaces tags for one user/host/session", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.replaceForUserHostSession("user-1", 1, "api", [ + "blue", + "green", + ]), + ).toBe(4); + + const tags = await repo.listByUserAndHost("user-1", 1); + expect(tags.get("api")).toEqual(["blue", "green"]); + expect(tags.get("worker")).toEqual(["batch"]); + + expect( + await repo.replaceForUserHostSession("user-1", 1, "worker", []), + ).toBe(1); + expect( + await repo.replaceForUserHostSession("user-1", 1, "missing", []), + ).toBe(0); + expect(writeCount).toBe(2); + }); + + it("deletes all tags for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(3); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserAndHost("user-1", 1)).toEqual(new Map()); + expect(await repo.listByUserAndHost("user-2", 2)).toEqual( + new Map([["api", ["other"]]]), + ); + expect(writeCount).toBe(1); + }); +}); diff --git a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts new file mode 100644 index 00000000..5ad97830 --- /dev/null +++ b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { TransferRecentRepository } from "../../../database/repositories/transfer-recent-repository.js"; + +describe("TransferRecentRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE transfer_recent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + source_host_id INTEGER NOT NULL, + dest_host_id INTEGER NOT NULL, + dest_path TEXT NOT NULL, + dest_path_label TEXT NOT NULL, + last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'source'), (2, 'user-1', 'dest-a'), (3, 'user-1', 'dest-b'), (4, 'user-2', 'other'); + `); + + return new TransferRecentRepository(context, onWrite); + } + + it("upserts, lists, and prunes recent transfer destinations", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForDestination( + "user-1", + { sourceHostId: 1, destHostId: 2, destPath: "/var/www" }, + "2026-01-01T00:00:00.000Z", + ); + await repo.upsertForDestination( + "user-1", + { + sourceHostId: 1, + destHostId: 3, + destPath: "/opt/app", + destPathLabel: "App", + }, + "2026-01-02T00:00:00.000Z", + ); + await repo.upsertForDestination( + "user-1", + { sourceHostId: 1, destHostId: 2, destPath: "/var/www" }, + "2026-01-03T00:00:00.000Z", + ); + + const recent = await repo.listBySourceHost("user-1", 1); + + expect(recent).toHaveLength(2); + expect(recent.map((entry) => entry.destPath)).toEqual([ + "/var/www", + "/opt/app", + ]); + expect(recent[0].lastUsed).toBe("2026-01-03T00:00:00.000Z"); + expect(recent[0].destPathLabel).toBe("/var/www"); + expect(writeCount).toBe(3); + + expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(1); + expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(0); + expect(await repo.listBySourceHost("user-1", 1)).toHaveLength(1); + expect(writeCount).toBe(4); + }); + + it("deletes recent transfer destinations by user and host references", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForDestination("user-1", { + sourceHostId: 1, + destHostId: 2, + destPath: "/one", + }); + await repo.upsertForDestination("user-1", { + sourceHostId: 2, + destHostId: 3, + destPath: "/two", + }); + await repo.upsertForDestination("user-2", { + sourceHostId: 4, + destHostId: 1, + destPath: "/other", + }); + expect(writeCount).toBe(3); + + expect(await repo.deleteByHostId(1)).toBe(2); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(writeCount).toBe(4); + + await repo.upsertForDestination("user-1", { + sourceHostId: 1, + destHostId: 2, + destPath: "/three", + }); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2, 3])).toBe(2); + expect(writeCount).toBe(6); + + await repo.upsertForDestination("user-2", { + sourceHostId: 4, + destHostId: 1, + destPath: "/last", + }); + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByUserId("user-2")).toBe(0); + expect(writeCount).toBe(8); + }); +}); diff --git a/src/backend/tests/database/repositories/trusted-device-repository.test.ts b/src/backend/tests/database/repositories/trusted-device-repository.test.ts new file mode 100644 index 00000000..290bc10c --- /dev/null +++ b/src/backend/tests/database/repositories/trusted-device-repository.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { TrustedDeviceRepository } from "../../../database/repositories/trusted-device-repository.js"; + +describe("TrustedDeviceRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite?: () => void): Promise<{ + trustedDevices: TrustedDeviceRepository; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE trusted_devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_fingerprint TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'admin', 'hash'), + ('user-2', 'user', 'hash'); + `); + + return { + trustedDevices: new TrustedDeviceRepository(context, onWrite), + }; + } + + it("upserts, touches, finds, and deletes trusted devices", async () => { + const repo = await createRepository(); + + await repo.trustedDevices.upsert({ + id: "device-1", + userId: "user-1", + deviceFingerprint: "fingerprint", + deviceType: "desktop", + deviceInfo: "Firefox", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: "2026-07-26T00:00:00.000Z", + lastUsedAt: "2026-06-26T00:00:00.000Z", + }); + + expect( + ( + await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ) + )?.deviceType, + ).toBe("desktop"); + + await repo.trustedDevices.touch( + "user-1", + "fingerprint", + "2026-06-26T01:00:00.000Z", + ); + expect( + ( + await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ) + )?.lastUsedAt, + ).toBe("2026-06-26T01:00:00.000Z"); + + await repo.trustedDevices.upsert({ + id: "device-ignored", + userId: "user-1", + deviceFingerprint: "fingerprint", + deviceType: "mobile", + deviceInfo: "Safari", + expiresAt: "2026-08-26T00:00:00.000Z", + lastUsedAt: "2026-06-26T02:00:00.000Z", + }); + + const updated = await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ); + expect(updated?.id).toBe("device-1"); + expect(updated?.deviceType).toBe("desktop"); + expect(updated?.expiresAt).toBe("2026-08-26T00:00:00.000Z"); + + await repo.trustedDevices.deleteByUserAndFingerprint( + "user-1", + "fingerprint", + ); + expect( + await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ), + ).toBeNull(); + }); + + it("deletes all trusted devices for a user", async () => { + const repo = await createRepository(); + + for (const id of ["device-1", "device-2"]) { + await repo.trustedDevices.upsert({ + id, + userId: "user-2", + deviceFingerprint: id, + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-07-26T00:00:00.000Z", + }); + } + + await repo.trustedDevices.deleteByUserId("user-2"); + + expect( + await repo.trustedDevices.findByUserAndFingerprint("user-2", "device-1"), + ).toBeNull(); + expect( + await repo.trustedDevices.findByUserAndFingerprint("user-2", "device-2"), + ).toBeNull(); + }); + + it("runs the write hook after trusted device writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.trustedDevices.upsert({ + id: "device-1", + userId: "user-1", + deviceFingerprint: "fingerprint", + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-07-26T00:00:00.000Z", + }); + await repo.trustedDevices.touch("user-1", "fingerprint"); + await repo.trustedDevices.deleteByUserAndFingerprint( + "user-1", + "fingerprint", + ); + + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts new file mode 100644 index 00000000..d52c2c35 --- /dev/null +++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { UserDataExportRepository } from "../../../database/repositories/user-data-export-repository.js"; + +describe("UserDataExportRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT, + password TEXT, + key TEXT, + private_key TEXT, + public_key TEXT, + key_password TEXT, + key_type TEXT, + detected_key_type TEXT, + cert_public_key TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES + (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'), + (2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password'); + + INSERT INTO ssh_credentials (id, user_id, name, auth_type, username, password) + VALUES + (1, 'user-1', 'prod', 'password', 'root', 'secret'), + (2, 'user-2', 'other', 'password', 'root', 'secret'); + `); + + return new UserDataExportRepository(context); + } + + it("lists only the current user's exportable hosts and credentials", async () => { + const repository = await createRepository(); + + expect(await repository.listHostsByUserId("user-1")).toMatchObject([ + { + id: 1, + userId: "user-1", + name: "web", + ip: "10.0.0.1", + }, + ]); + + expect(await repository.listCredentialsByUserId("user-1")).toMatchObject([ + { + id: 1, + userId: "user-1", + name: "prod", + username: "root", + }, + ]); + }); +}); diff --git a/src/backend/tests/database/repositories/user-preference-repository.test.ts b/src/backend/tests/database/repositories/user-preference-repository.test.ts new file mode 100644 index 00000000..3ff56e19 --- /dev/null +++ b/src/backend/tests/database/repositories/user-preference-repository.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { UserPreferenceRepository } from "../../../database/repositories/user-preference-repository.js"; + +describe("UserPreferenceRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE user_preferences ( + user_id TEXT PRIMARY KEY, + reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0, + theme TEXT, + font_size TEXT, + accent_color TEXT, + language TEXT, + storage_mode TEXT, + command_autocomplete INTEGER, + command_palette_enabled INTEGER, + show_host_tags INTEGER, + host_tray_on_click INTEGER, + pin_app_rail INTEGER, + expand_app_rail_on_hover INTEGER, + folders_collapsed INTEGER, + confirm_snippet_execution INTEGER, + disable_update_check INTEGER, + confirm_tab_close INTEGER, + hidden_rail_tabs TEXT, + compact_host_view INTEGER, + status_color_scheme TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'); + `); + + return new UserPreferenceRepository(context, onWrite); + } + + it("finds, creates, and updates preferences by user id", async () => { + const repo = await createRepository(); + + expect(await repo.findByUserId("user-1")).toBeNull(); + + const created = await repo.upsert("user-1", { + reopenTabsOnLogin: true, + theme: "dark", + storageMode: "local", + commandAutocomplete: true, + }); + expect(created).toMatchObject({ + userId: "user-1", + reopenTabsOnLogin: true, + theme: "dark", + storageMode: "local", + commandAutocomplete: true, + }); + + const updated = await repo.upsert("user-1", { + theme: "light", + commandAutocomplete: false, + updatedAt: "2026-06-27T00:00:00.000Z", + }); + expect(updated).toMatchObject({ + userId: "user-1", + reopenTabsOnLogin: true, + theme: "light", + storageMode: "local", + commandAutocomplete: false, + }); + }); + + it("deletes preferences by user id only when rows exist", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsert("user-1", { theme: "dark" }); + expect(writeCount).toBe(1); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(1); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(2); + expect(await repo.findByUserId("user-1")).toBeNull(); + }); +}); diff --git a/src/backend/tests/database/repositories/user-session-repositories.test.ts b/src/backend/tests/database/repositories/user-session-repositories.test.ts new file mode 100644 index 00000000..e26c508a --- /dev/null +++ b/src/backend/tests/database/repositories/user-session-repositories.test.ts @@ -0,0 +1,296 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SessionRepository } from "../../../database/repositories/session-repository.js"; +import { UserRepository } from "../../../database/repositories/user-repository.js"; + +describe("UserRepository and SessionRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepositories(): Promise<{ + users: UserRepository; + sessions: SessionRepository; + }>; + async function createRepositories(options: { + onUserWrite?: () => void | Promise; + onSessionWrite?: () => void | Promise; + }): Promise<{ + users: UserRepository; + sessions: SessionRepository; + }>; + async function createRepositories( + options: { + onUserWrite?: () => void | Promise; + onSessionWrite?: () => void | Promise; + } = {}, + ): Promise<{ + users: UserRepository; + sessions: SessionRepository; + }> { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0, + oidc_identifier TEXT, + sso_provider_id INTEGER, + client_id TEXT, + client_secret TEXT, + issuer_url TEXT, + authorization_url TEXT, + token_url TEXT, + identifier_path TEXT, + name_path TEXT, + scopes TEXT DEFAULT 'openid email profile', + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + totp_backup_codes TEXT, + registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + donation_modal_dismissed INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + jwt_token TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + oidc_sub TEXT, + oidc_sid TEXT, + sso_provider_id INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + + return { + users: new UserRepository(context, options.onUserWrite), + sessions: new SessionRepository(context, options.onSessionWrite), + }; + } + + it("creates, finds, updates, and deletes users", async () => { + const repo = await createRepositories(); + + await repo.users.create({ + id: "user-1", + username: "admin", + passwordHash: "hash", + isAdmin: true, + isOidc: false, + }); + + expect(await repo.users.countAdmins()).toBe(1); + expect(await repo.users.countTotpEnabled()).toBe(0); + expect((await repo.users.listAll()).map((user) => user.id)).toEqual([ + "user-1", + ]); + expect((await repo.users.findByUsername("admin"))?.id).toBe("user-1"); + expect( + (await repo.users.listByIds(["user-1", "user-1"])).map((u) => u.id), + ).toEqual(["user-1"]); + expect(await repo.users.listByIds([])).toEqual([]); + + const updated = await repo.users.update("user-1", { + oidcIdentifier: "oidc:admin", + totpEnabled: true, + }); + expect(updated?.oidcIdentifier).toBe("oidc:admin"); + expect(await repo.users.countTotpEnabled()).toBe(1); + expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe( + "user-1", + ); + + expect(await repo.users.delete("user-1")).toBe(true); + expect(await repo.users.findById("user-1")).toBeNull(); + }); + + it("creates the first local user as admin inside the repository", async () => { + const repo = await createRepositories(); + + const first = await repo.users.createFirstLocalUser({ + id: "user-1", + username: "first", + passwordHash: "hash", + isOidc: false, + }); + const second = await repo.users.createFirstLocalUser({ + id: "user-2", + username: "second", + passwordHash: "hash", + isOidc: false, + }); + + expect(first.isFirstUser).toBe(true); + expect(first.user.isAdmin).toBe(true); + expect(second.isFirstUser).toBe(false); + expect(second.user.isAdmin).toBe(false); + expect(await repo.users.countAll()).toBe(2); + }); + + it("creates SSO users with first-user and provider admin semantics", async () => { + const repo = await createRepositories(); + + const first = await repo.users.createFirstSsoUser({ + id: "user-1", + username: "first", + passwordHash: "", + isAdmin: false, + isOidc: true, + oidcIdentifier: "ldap:provider:first", + ssoProviderId: 1, + }); + const providerAdmin = await repo.users.createFirstSsoUser({ + id: "user-2", + username: "provider-admin", + passwordHash: "", + isAdmin: true, + isOidc: true, + oidcIdentifier: "ldap:provider:admin", + ssoProviderId: 1, + }); + const regular = await repo.users.createFirstSsoUser({ + id: "user-3", + username: "regular", + passwordHash: "", + isAdmin: false, + isOidc: true, + oidcIdentifier: "ldap:provider:regular", + ssoProviderId: 1, + }); + + expect(first.isFirstUser).toBe(true); + expect(first.user.isAdmin).toBe(true); + expect(providerAdmin.isFirstUser).toBe(false); + expect(providerAdmin.user.isAdmin).toBe(true); + expect(regular.isFirstUser).toBe(false); + expect(regular.user.isAdmin).toBe(false); + expect(await repo.users.countAll()).toBe(3); + }); + + it("runs the user write hook after user writes", async () => { + let writeCount = 0; + const repo = await createRepositories({ + onUserWrite: () => { + writeCount += 1; + }, + }); + + await repo.users.create({ + id: "user-1", + username: "admin", + passwordHash: "hash", + isAdmin: true, + isOidc: false, + }); + await repo.users.update("user-1", { isAdmin: false }); + await repo.users.delete("user-1"); + + expect(writeCount).toBe(3); + }); + + it("creates, touches, lists, and revokes sessions", async () => { + const repo = await createRepositories(); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + + await repo.sessions.create({ + id: "session-1", + userId: "user-1", + jwtToken: "token", + deviceType: "desktop", + deviceInfo: "Firefox", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: "2026-06-27T00:00:00.000Z", + lastActiveAt: "2026-06-26T00:00:00.000Z", + }); + + await repo.sessions.touch("session-1", "2026-06-26T01:00:00.000Z"); + + expect((await repo.sessions.findById("session-1"))?.lastActiveAt).toBe( + "2026-06-26T01:00:00.000Z", + ); + expect(await repo.sessions.listByUserId("user-1")).toHaveLength(1); + expect(await repo.sessions.revoke("session-1")).toBe(true); + expect(await repo.sessions.findById("session-1")).toBeNull(); + }); + + it("revokes all user sessions except an optional current session", async () => { + const repo = await createRepositories(); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + + for (const id of ["keep", "drop-1", "drop-2"]) { + await repo.sessions.create({ + id, + userId: "user-1", + jwtToken: `${id}-token`, + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-06-27T00:00:00.000Z", + }); + } + + expect(await repo.sessions.revokeAllForUser("user-1", "keep")).toBe(2); + expect( + (await repo.sessions.listByUserId("user-1")).map((s) => s.id), + ).toEqual(["keep"]); + }); + + it("deletes expired sessions", async () => { + const repo = await createRepositories(); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + + await repo.sessions.create({ + id: "expired", + userId: "user-1", + jwtToken: "expired-token", + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-06-25T00:00:00.000Z", + }); + await repo.sessions.create({ + id: "active", + userId: "user-1", + jwtToken: "active-token", + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-06-27T00:00:00.000Z", + }); + + expect( + await repo.sessions.deleteExpired(new Date("2026-06-26T00:00:00.000Z")), + ).toBe(1); + expect( + (await repo.sessions.listByUserId("user-1")).map((s) => s.id), + ).toEqual(["active"]); + }); +}); diff --git a/src/backend/tests/database/repositories/vault-profile-repository.test.ts b/src/backend/tests/database/repositories/vault-profile-repository.test.ts new file mode 100644 index 00000000..2c6cdfc9 --- /dev/null +++ b/src/backend/tests/database/repositories/vault-profile-repository.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { VaultProfileRepository } from "../../../database/repositories/vault-profile-repository.js"; + +describe("VaultProfileRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE vault_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + vault_addr TEXT NOT NULL, + vault_namespace TEXT, + oidc_mount TEXT, + oidc_role TEXT, + ssh_mount TEXT, + ssh_role TEXT NOT NULL, + valid_principals TEXT, + key_type TEXT, + shared INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO vault_profiles ( + id, user_id, name, vault_addr, ssh_role, shared, updated_at + ) + VALUES + (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'), + (2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'), + (3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z'); + `); + + return new VaultProfileRepository(context, onWrite); + } + + it("lists profiles owned by or shared with the user", async () => { + const repo = await createRepository(); + + const rows = await repo.listVisibleToUser("user-1"); + + expect(rows.map((row) => row.id)).toEqual([2, 1]); + }); + + it("creates and reads a profile", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const created = await repo.create({ + userId: "user-1", + name: "new", + description: "desc", + folder: "folder", + tags: "prod,ssh", + vaultAddr: "https://vault.new", + vaultNamespace: "ns", + oidcMount: "oidc", + oidcRole: "oidc-role", + sshMount: "ssh", + sshRole: "ssh-role", + validPrincipals: "root", + keyType: "ed25519", + shared: true, + }); + + const found = await repo.findById(created.id); + expect(found).toMatchObject({ + userId: "user-1", + name: "new", + vaultAddr: "https://vault.new", + sshRole: "ssh-role", + shared: true, + }); + expect(writeCount).toBe(1); + }); + + it("updates and deletes profiles", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.updateById(1, { + name: "renamed", + shared: true, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + expect(updated).toMatchObject({ + id: 1, + name: "renamed", + shared: true, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + + expect(await repo.updateById(999, { name: "missing" })).toBeNull(); + expect(await repo.deleteById(1)).toBe(true); + expect(await repo.deleteById(1)).toBe(false); + expect(await repo.findById(1)).toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes all profiles for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect(repo.deleteByUserId("user-2")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findById(2)).toBeNull(); + expect(await repo.findById(3)).toBeNull(); + expect((await repo.findById(1))?.userId).toBe("user-1"); + expect(writeCount).toBe(1); + }); +}); diff --git a/src/backend/tests/database/repositories/vault-token-repository.test.ts b/src/backend/tests/database/repositories/vault-token-repository.test.ts new file mode 100644 index 00000000..a67d104d --- /dev/null +++ b/src/backend/tests/database/repositories/vault-token-repository.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { VaultTokenRepository } from "../../../database/repositories/vault-token-repository.js"; + +describe("VaultTokenRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE vault_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE vault_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + profile_id INTEGER NOT NULL, + ssh_cert TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used TEXT, + UNIQUE(user_id, profile_id) + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO vault_profiles (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO vault_tokens ( + user_id, profile_id, ssh_cert, private_key, expires_at + ) + VALUES + ('user-1', 1, 'cert-1', 'key-1', '2099-01-01T00:00:00.000Z'), + ('user-2', 2, 'cert-2', 'key-2', '2099-01-01T00:00:00.000Z'); + `); + + return new VaultTokenRepository(context, onWrite); + } + + it("finds and upserts a token by user and profile", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const existing = await repo.findByUserAndProfile("user-1", 1); + expect(existing?.sshCert).toBe("cert-1"); + + await repo.upsert({ + userId: "user-1", + profileId: 1, + sshCert: "new-cert", + privateKey: "new-key", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + const updated = await repo.findByUserAndProfile("user-1", 1); + expect(updated).toMatchObject({ + sshCert: "new-cert", + privateKey: "new-key", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(writeCount).toBe(1); + }); + + it("updates last-used and deletes one token", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"), + ).toBe(true); + expect(await repo.updateLastUsed("missing", 1)).toBe(false); + expect((await repo.findByUserAndProfile("user-1", 1))?.lastUsed).toBe( + "2026-01-02T00:00:00.000Z", + ); + + expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(true); + expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(false); + expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull(); + expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes all tokens for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsert({ + userId: "user-1", + profileId: 2, + sshCert: "cert-extra", + privateKey: "key-extra", + expiresAt: "2099-03-01T00:00:00.000Z", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull(); + expect(await repo.findByUserAndProfile("user-1", 2)).toBeNull(); + expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull(); + expect(writeCount).toBe(2); + }); +}); diff --git a/src/backend/tests/database/routes/donation-modal-utils.test.ts b/src/backend/tests/database/routes/donation-modal-utils.test.ts new file mode 100644 index 00000000..823b9e9c --- /dev/null +++ b/src/backend/tests/database/routes/donation-modal-utils.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { shouldShowDonationModal } from "../../../database/routes/donation-modal-utils.js"; + +describe("shouldShowDonationModal", () => { + const now = Date.parse("2026-07-17T00:00:00.000Z"); + + it("returns false when the user already dismissed it", () => { + const registeredAt = new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString(); + expect(shouldShowDonationModal(registeredAt, true, now)).toBe(false); + }); + + it("returns false before the 30 day mark", () => { + const registeredAt = new Date(now - 29 * 24 * 60 * 60 * 1000).toISOString(); + expect(shouldShowDonationModal(registeredAt, false, now)).toBe(false); + }); + + it("returns true at exactly 30 days", () => { + const registeredAt = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(); + expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true); + }); + + it("returns true well past the 30 day mark", () => { + const registeredAt = new Date( + now - 200 * 24 * 60 * 60 * 1000, + ).toISOString(); + expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true); + }); + + it("returns false for an unparseable registeredAt", () => { + expect(shouldShowDonationModal("not-a-date", false, now)).toBe(false); + }); + + it("treats a backdated registeredAt for pre-existing users as immediately eligible", () => { + const registeredAt = new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString(); + expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true); + }); +}); diff --git a/src/backend/database/routes/host-bulk-routes.test.ts b/src/backend/tests/database/routes/host-bulk-routes.test.ts similarity index 96% rename from src/backend/database/routes/host-bulk-routes.test.ts rename to src/backend/tests/database/routes/host-bulk-routes.test.ts index d105df9e..5904258c 100644 --- a/src/backend/database/routes/host-bulk-routes.test.ts +++ b/src/backend/tests/database/routes/host-bulk-routes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseSSHConfig } from "./host-bulk-routes.js"; +import { parseSSHConfig } from "../../../database/routes/host-bulk-routes.js"; describe("parseSSHConfig", () => { it("parses a basic Host block", () => { diff --git a/src/backend/tests/database/routes/host-enrollment-auth.test.ts b/src/backend/tests/database/routes/host-enrollment-auth.test.ts new file mode 100644 index 00000000..e73d6e53 --- /dev/null +++ b/src/backend/tests/database/routes/host-enrollment-auth.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isUserDataUnlocked: vi.fn(), +})); + +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + canUserAccessData: mocks.isUserDataUnlocked, + }, +})); + +const { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath } = + await import("../../../database/routes/host-enrollment-auth.js"); + +function response() { + const json = vi.fn(); + const status = vi.fn(() => ({ json })); + return { status, json }; +} + +describe("requireHostEnrollmentAccessForPath", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isUserDataUnlocked.mockReturnValue(true); + }); + + it("accepts an API key scoped to an unlocked user", () => { + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never, + res as never, + next, + ); + + expect(mocks.isUserDataUnlocked).toHaveBeenCalledWith("user-1"); + expect(next).toHaveBeenCalledOnce(); + }); + + it("rejects regular JWT sessions", () => { + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/enroll", userId: "user-1" } as never, + res as never, + next, + ); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: "Host enrollment requires an API key", + code: "API_KEY_REQUIRED", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("reports locked encrypted data explicitly", () => { + mocks.isUserDataUnlocked.mockReturnValue(false); + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never, + res as never, + next, + ); + + expect(res.status).toHaveBeenCalledWith(423); + expect(res.json).toHaveBeenCalledWith({ + error: "User data is locked. Sign in before enrolling hosts.", + code: "DATA_LOCKED", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("leaves the existing host route unchanged", () => { + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/db/host", userId: "user-1" } as never, + res as never, + next, + ); + + expect(next).toHaveBeenCalledOnce(); + expect(mocks.isUserDataUnlocked).not.toHaveBeenCalled(); + }); +}); + +describe("applyHostEnrollmentDefaults", () => { + it("creates a usable SSH host from a minimal enrollment payload", () => { + expect(applyHostEnrollmentDefaults({ ip: "server.example" })).toEqual({ + connectionType: "ssh", + ip: "server.example", + port: 22, + authType: "none", + enableTerminal: true, + enableSsh: true, + }); + }); + + it("preserves explicit enrollment settings", () => { + expect( + applyHostEnrollmentDefaults({ + ip: "server.example", + port: 2222, + authType: "password", + enableTerminal: false, + }), + ).toMatchObject({ + port: 2222, + authType: "password", + enableTerminal: false, + }); + }); +}); diff --git a/src/backend/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts similarity index 80% rename from src/backend/database/routes/host-normalizers.test.ts rename to src/backend/tests/database/routes/host-normalizers.test.ts index 7dae39f6..eb0a863f 100644 --- a/src/backend/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -4,9 +4,10 @@ import { isValidPort, normalizeImportedHost, renameFolderPath, + sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, -} from "./host-normalizers.js"; +} from "../../../database/routes/host-normalizers.js"; describe("isNonEmptyString", () => { it("accepts non-blank strings", () => { @@ -219,3 +220,58 @@ describe("transformHostResponse", () => { expect(result.proxmoxConfig).toBeUndefined(); }); }); + +describe("sanitizeHostForRecipient", () => { + const sharedHost = { + id: 42, + userId: "owner", + ownerUsername: "owner", + isShared: true, + permissionLevel: "view", + name: "prod", + ip: "10.0.0.42", + port: 22, + username: "root", + folder: "servers", + tags: ["linux"], + notes: "secret runbook", + quickActions: [{ name: "restart", snippetId: "1" }], + password: "hunter2", + key: "PRIVATE", + sudoPassword: "sudo", + rdpPassword: "rdp", + socks5Password: "socks", + enableSsh: true, + enableRdp: true, + sshPort: 22, + rdpPort: 3389, + defaultPath: "/srv", + }; + + it("always strips secrets for recipients", () => { + const result = sanitizeHostForRecipient({ ...sharedHost }, "view"); + expect(result.password).toBeUndefined(); + expect(result.key).toBeUndefined(); + expect(result.sudoPassword).toBeUndefined(); + expect(result.rdpPassword).toBeUndefined(); + expect(result.socks5Password).toBeUndefined(); + // view keeps configuration fields + expect(result.notes).toBe("secret runbook"); + expect(result.quickActions).toEqual(sharedHost.quickActions); + }); + + it("reduces connect-level hosts to connection essentials", () => { + const result = sanitizeHostForRecipient( + { ...sharedHost, permissionLevel: "connect" }, + "connect", + ); + expect(result.name).toBe("prod"); + expect(result.ip).toBe("10.0.0.42"); + expect(result.enableRdp).toBe(true); + expect(result.rdpPort).toBe(3389); + expect(result.permissionLevel).toBe("connect"); + expect(result.notes).toBeUndefined(); + expect(result.quickActions).toBeUndefined(); + expect(result.password).toBeUndefined(); + }); +}); diff --git a/src/backend/database/routes/service-link-url.test.ts b/src/backend/tests/database/routes/service-link-url.test.ts similarity index 93% rename from src/backend/database/routes/service-link-url.test.ts rename to src/backend/tests/database/routes/service-link-url.test.ts index 353d4da6..51919df9 100644 --- a/src/backend/database/routes/service-link-url.test.ts +++ b/src/backend/tests/database/routes/service-link-url.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isValidServiceLinkUrl, normalizeServiceLinkUrl, -} from "./service-link-url.js"; +} from "../../../database/routes/service-link-url.js"; describe("service link URL handling", () => { it("keeps explicit http and https URLs", () => { diff --git a/src/backend/database/routes/session-log-routes.test.ts b/src/backend/tests/database/routes/session-log-routes.test.ts similarity index 93% rename from src/backend/database/routes/session-log-routes.test.ts rename to src/backend/tests/database/routes/session-log-routes.test.ts index 2cb8c233..1bbbbf54 100644 --- a/src/backend/database/routes/session-log-routes.test.ts +++ b/src/backend/tests/database/routes/session-log-routes.test.ts @@ -6,7 +6,7 @@ const mockSelect = vi.fn(); const mockDelete = vi.fn(); const mockInsert = vi.fn(); -vi.mock("../db/index.js", () => ({ +vi.mock("../../../database/db/index.js", () => ({ db: { select: mockSelect, delete: mockDelete, @@ -14,11 +14,11 @@ vi.mock("../db/index.js", () => ({ }, })); -vi.mock("../../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() }, })); -vi.mock("../../utils/auth-manager.js", () => ({ +vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: @@ -92,7 +92,8 @@ describe("session-log-routes", () => { mockStat.mockReturnValue({ size: 4096 }); // Directly call the route handler extracted from the module - const { default: router } = await import("./session-log-routes.js"); + const { default: router } = + await import("../../../database/routes/session-log-routes.js"); expect(router).toBeDefined(); }); }); diff --git a/src/backend/database/routes/ssh-certificate.test.ts b/src/backend/tests/database/routes/ssh-certificate.test.ts similarity index 98% rename from src/backend/database/routes/ssh-certificate.test.ts rename to src/backend/tests/database/routes/ssh-certificate.test.ts index a884f8d7..c7bd1b23 100644 --- a/src/backend/database/routes/ssh-certificate.test.ts +++ b/src/backend/tests/database/routes/ssh-certificate.test.ts @@ -8,7 +8,7 @@ import { generateCa, signUserCertificate, ed25519RawFromLine, -} from "./ssh-certificate.js"; +} from "../../../database/routes/ssh-certificate.js"; function publicKeyObjectFromLine(line: string) { const raw = ed25519RawFromLine(line); diff --git a/src/backend/database/routes/termix-id-keys.test.ts b/src/backend/tests/database/routes/termix-id-keys.test.ts similarity index 98% rename from src/backend/database/routes/termix-id-keys.test.ts rename to src/backend/tests/database/routes/termix-id-keys.test.ts index db640293..917b53b9 100644 --- a/src/backend/database/routes/termix-id-keys.test.ts +++ b/src/backend/tests/database/routes/termix-id-keys.test.ts @@ -4,7 +4,7 @@ import { parsePublicKey, matchesAlgoFilter, MAX_PUBLIC_KEY_LENGTH, -} from "./termix-id-keys.js"; +} from "../../../database/routes/termix-id-keys.js"; // Build a valid OpenSSH public-key line for a given type by encoding a wire // blob whose first string field equals the type (what parsePublicKey checks). diff --git a/src/backend/database/routes/termix-id.test.ts b/src/backend/tests/database/routes/termix-id.test.ts similarity index 67% rename from src/backend/database/routes/termix-id.test.ts rename to src/backend/tests/database/routes/termix-id.test.ts index 32d1e983..ee3736f8 100644 --- a/src/backend/database/routes/termix-id.test.ts +++ b/src/backend/tests/database/routes/termix-id.test.ts @@ -5,7 +5,7 @@ const mockUpdate = vi.fn(); const mockInsert = vi.fn(); const mockDelete = vi.fn(); -vi.mock("../db/index.js", () => ({ +vi.mock("../../../database/db/index.js", () => ({ db: { select: mockSelect, update: mockUpdate, @@ -14,7 +14,7 @@ vi.mock("../db/index.js", () => ({ }, })); -vi.mock("../../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() }, authLogger: { error: vi.fn(), @@ -24,7 +24,7 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -vi.mock("../../utils/auth-manager.js", () => ({ +vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: @@ -40,34 +40,45 @@ vi.mock("../../utils/auth-manager.js", () => ({ }, })); -vi.mock("../../utils/audit-logger.js", () => ({ +vi.mock("../../../utils/audit-logger.js", () => ({ logAudit: vi.fn(), getRequestMeta: vi.fn(() => ({})), })); -vi.mock("../../utils/data-crypto.js", () => ({ +vi.mock("../../../utils/data-crypto.js", () => ({ DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) }, })); -vi.mock("../../utils/user-crypto.js", () => ({ - UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) }, +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentTermixIdentityCaRepository: vi.fn(() => ({ + findPublicByIdentityId: vi.fn(), + findDecryptedByIdentityId: vi.fn(), + createEncryptedForUser: vi.fn(), + updateEncryptedForIdentity: vi.fn(), + deleteByIdentityId: vi.fn(), + })), + createCurrentTermixIdentityRepository: vi.fn(() => ({ + findIdentityForUser: vi.fn(), + findIdentityByHandle: vi.fn(), + isHandleTaken: vi.fn(), + createIdentity: vi.fn(), + updateIdentityForUser: vi.fn(), + deleteIdentityForUser: vi.fn(), + listKeysByIdentityId: vi.fn(), + listEnabledKeysByIdentityId: vi.fn(), + listLinkedCredentialIds: vi.fn(), + createKey: vi.fn(), + updateKeyForUser: vi.fn(), + deleteKeyForUser: vi.fn(), + findKeyForUser: vi.fn(), + })), })); -vi.mock("./termix-id-keys.js", () => ({ +vi.mock("../../../database/routes/termix-id-keys.js", () => ({ termixIdKeysRouter: { use: vi.fn() }, matchesAlgoFilter: vi.fn(() => true), })); -vi.mock("../../utils/simple-db-ops.js", () => ({ - SimpleDBOps: vi.fn().mockImplementation(() => ({ - findOne: vi.fn(), - findAll: vi.fn(), - insert: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - })), -})); - // Chainable Drizzle stub — supports arbitrary method chains and resolves via .then() function makeChain(resolveValue: unknown) { const chain: Record = {}; @@ -106,18 +117,20 @@ describe("GET /termix-id/linked-credentials", () => { // First select (getIdentityForUser) returns nothing; second should not be called mockSelect.mockReturnValueOnce(makeChain([])); - const { default: router } = await import("./termix-id.js"); + const { default: router } = + await import("../../../database/routes/termix-id.js"); expect(router).toBeDefined(); expect(router).toBeDefined(); - }); + }, 15_000); it("returns empty list when identity has no keys", async () => { mockSelect .mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup .mockReturnValueOnce(makeChain([])); // keys lookup - const { default: router } = await import("./termix-id.js"); + const { default: router } = + await import("../../../database/routes/termix-id.js"); expect(router).toBeDefined(); }); @@ -131,7 +144,8 @@ describe("GET /termix-id/linked-credentials", () => { .mockReturnValueOnce(makeChain([IDENTITY_ROW])) .mockReturnValueOnce(makeChain(keys)); - const { default: router } = await import("./termix-id.js"); + const { default: router } = + await import("../../../database/routes/termix-id.js"); expect(router).toBeDefined(); }); @@ -141,7 +155,8 @@ describe("GET /termix-id/linked-credentials", () => { .mockReturnValueOnce(makeChain([IDENTITY_ROW])) .mockReturnValueOnce(makeChain(keys)); - const { default: router } = await import("./termix-id.js"); + const { default: router } = + await import("../../../database/routes/termix-id.js"); expect(router).toBeDefined(); }); }); diff --git a/src/backend/tests/database/routes/user-admin-routes.test.ts b/src/backend/tests/database/routes/user-admin-routes.test.ts new file mode 100644 index 00000000..22efb678 --- /dev/null +++ b/src/backend/tests/database/routes/user-admin-routes.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, RequestHandler, Response } from "express"; + +const state = vi.hoisted(() => ({ + currentUserId: "admin1", + users: new Map< + string, + { + id: string; + username: string; + isAdmin: boolean; + isOidc: boolean; + passwordHash: string | null; + totpEnabled: boolean; + } + >(), + unlockedUsers: new Set(), + updates: [] as { id: string; changes: Record }[], + auditCalls: [] as Record[], +})); + +vi.mock("../../../database/db/index.js", () => ({ db: {} })); + +vi.mock("../../../utils/logger.js", () => ({ + authLogger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) }, +})); + +vi.mock("../../../utils/audit-logger.js", () => ({ + logAudit: async (params: Record) => { + state.auditCalls.push(params); + }, + getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), +})); + +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + canUserAccessData: (userId: string) => state.unlockedUsers.has(userId), + }, +})); + +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => ({}) }, +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + listAll: async () => [...state.users.values()], + findById: async (id: string) => state.users.get(id) ?? null, + findByUsername: async (username: string) => + [...state.users.values()].find((u) => u.username === username) ?? null, + update: async (id: string, changes: Record) => { + state.updates.push({ id, changes }); + const user = state.users.get(id); + if (user) Object.assign(user, changes); + }, + }), + createCurrentRoleRepository: () => ({ + switchUserRoleName: async () => {}, + assignRoleNameToUser: async () => {}, + }), +})); + +const { registerUserAdminRoutes } = + await import("../../../database/routes/user-admin-routes.js"); + +// Capture the handlers registered on the router so we can invoke them directly +// without spinning up an HTTP server. +type Registered = { method: string; path: string; handler: RequestHandler }; +const registered: Registered[] = []; + +function fakeRouter() { + const record = + (method: string) => + (path: string, ...handlers: RequestHandler[]) => { + registered.push({ method, path, handler: handlers[handlers.length - 1] }); + }; + return { + get: record("get"), + post: record("post"), + put: record("put"), + delete: record("delete"), + } as unknown as import("express").Router; +} + +registerUserAdminRoutes(fakeRouter(), (_req, _res, next) => next()); + +function findHandler(method: string, path: string): RequestHandler { + const match = registered.find((r) => r.method === method && r.path === path); + if (!match) throw new Error(`No handler for ${method} ${path}`); + return match.handler; +} + +function makeReqRes(overrides: { + body?: Record; + params?: Record; +}) { + const req = { + userId: state.currentUserId, + body: overrides.body ?? {}, + params: overrides.params ?? {}, + headers: {}, + } as unknown as Request; + + const res = { + statusCode: 200, + jsonBody: null as unknown, + headers: {} as Record, + status(code: number) { + (this as unknown as { statusCode: number }).statusCode = code; + return this; + }, + json(payload: unknown) { + (this as unknown as { jsonBody: unknown }).jsonBody = payload; + return this; + }, + setHeader(key: string, value: string) { + (this as unknown as { headers: Record }).headers[key] = + value; + return this; + }, + } as unknown as Response & { + statusCode: number; + jsonBody: unknown; + headers: Record; + }; + + return { req, res }; +} + +async function invoke( + method: string, + path: string, + overrides: { + body?: Record; + params?: Record; + } = {}, +) { + const handler = findHandler(method, path); + const { req, res } = makeReqRes(overrides); + await handler(req, res as unknown as Response, () => {}); + return res as unknown as { + statusCode: number; + jsonBody: Record | null; + }; +} + +beforeEach(() => { + state.currentUserId = "admin1"; + state.users = new Map([ + [ + "admin1", + { + id: "admin1", + username: "admin", + isAdmin: true, + isOidc: false, + passwordHash: "hash", + totpEnabled: false, + }, + ], + [ + "target1", + { + id: "target1", + username: "target", + isAdmin: false, + isOidc: false, + passwordHash: "hash", + totpEnabled: true, + }, + ], + [ + "locked1", + { + id: "locked1", + username: "locked", + isAdmin: false, + isOidc: false, + passwordHash: "hash", + totpEnabled: false, + }, + ], + ]); + state.unlockedUsers = new Set(["admin1", "target1"]); + state.updates = []; + state.auditCalls = []; +}); + +describe("GET /list", () => { + it("includes data_unlocked and totp_enabled for admin callers", async () => { + const res = await invoke("get", "/list"); + expect(res.statusCode).toBe(200); + const users = (res.jsonBody as { users: Record[] }).users; + const target = users.find((u) => u.userId === "target1")!; + expect(target.data_unlocked).toBe(true); + expect(target.totp_enabled).toBe(true); + const locked = users.find((u) => u.userId === "locked1")!; + expect(locked.data_unlocked).toBe(false); + }); + + it("omits management fields for non-admin callers", async () => { + state.currentUserId = "target1"; + const res = await invoke("get", "/list"); + const users = (res.jsonBody as { users: Record[] }).users; + expect(users[0].data_unlocked).toBeUndefined(); + expect(users[0].totp_enabled).toBeUndefined(); + }); +}); + +describe("POST /admin/totp/disable", () => { + it("clears TOTP fields for the target and audits", async () => { + const res = await invoke("post", "/admin/totp/disable", { + body: { userId: "target1" }, + }); + expect(res.statusCode).toBe(200); + const update = state.updates.find((u) => u.id === "target1"); + expect(update?.changes).toMatchObject({ + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); + expect( + state.auditCalls.some((c) => c.action === "admin_disable_totp"), + ).toBe(true); + }); + + it("403s when the caller is not an admin", async () => { + state.currentUserId = "target1"; + const res = await invoke("post", "/admin/totp/disable", { + body: { userId: "locked1" }, + }); + expect(res.statusCode).toBe(403); + }); + + it("400s when TOTP is not enabled for the target", async () => { + const res = await invoke("post", "/admin/totp/disable", { + body: { userId: "locked1" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("404s for an unknown target", async () => { + const res = await invoke("post", "/admin/totp/disable", { + body: { userId: "ghost" }, + }); + expect(res.statusCode).toBe(404); + }); +}); + +describe("GET /admin/export/:userId", () => { + it("423s when the target's data is locked", async () => { + const res = await invoke("get", "/admin/export/:userId", { + params: { userId: "locked1" }, + }); + expect(res.statusCode).toBe(423); + expect((res.jsonBody as { code?: string }).code).toBe("TARGET_DATA_LOCKED"); + }); + + it("403s when the caller is not an admin", async () => { + state.currentUserId = "target1"; + const res = await invoke("get", "/admin/export/:userId", { + params: { userId: "admin1" }, + }); + expect(res.statusCode).toBe(403); + }); +}); diff --git a/src/backend/database/routes/user-oidc-utils.test.ts b/src/backend/tests/database/routes/user-oidc-utils.test.ts similarity index 79% rename from src/backend/database/routes/user-oidc-utils.test.ts rename to src/backend/tests/database/routes/user-oidc-utils.test.ts index 7ce97ade..96dcc723 100644 --- a/src/backend/database/routes/user-oidc-utils.test.ts +++ b/src/backend/tests/database/routes/user-oidc-utils.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // user-oidc-utils imports the logger; stub it so importing stays side-effect-free. -vi.mock("../../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ authLogger: { debug: vi.fn(), info: vi.fn(), @@ -11,8 +11,15 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } = - await import("./user-oidc-utils.js"); +const { + isOIDCUserAllowed, + getOIDCConfigFromEnv, + extractOidcGroups, + validateLogoutTokenClaims, +} = await import("../../../database/routes/user-oidc-utils.js"); + +const BACKCHANNEL_LOGOUT_EVENT = + "http://schemas.openid.net/event/backchannel-logout"; describe("isOIDCUserAllowed", () => { it("allows everyone when the allow-list is empty", () => { @@ -199,3 +206,48 @@ describe("extractOidcGroups", () => { expect(extractOidcGroups({})).toEqual([]); }); }); + +describe("validateLogoutTokenClaims", () => { + const validClaims = { + sub: "subject-1", + sid: "session-1", + iat: 1_783_641_600, + jti: "logout-1", + events: { [BACKCHANNEL_LOGOUT_EVENT]: {} }, + }; + + it("accepts a spec-compliant back-channel logout payload", () => { + expect(validateLogoutTokenClaims(validClaims)).toEqual({ + sub: "subject-1", + sid: "session-1", + jti: "logout-1", + }); + }); + + it("requires the logout event to contain an object", () => { + expect(() => + validateLogoutTokenClaims({ + ...validClaims, + events: { [BACKCHANNEL_LOGOUT_EVENT]: true }, + }), + ).toThrow("missing back-channel logout event"); + }); + + it("requires iat and jti claims", () => { + expect(() => + validateLogoutTokenClaims({ ...validClaims, iat: undefined }), + ).toThrow("missing iat claim"); + expect(() => + validateLogoutTokenClaims({ ...validClaims, jti: "" }), + ).toThrow("missing jti claim"); + }); + + it("rejects nonce and requires sub or sid", () => { + expect(() => + validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }), + ).toThrow("must not contain a nonce"); + expect(() => + validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }), + ).toThrow("must contain sub and/or sid"); + }); +}); diff --git a/src/backend/tests/database/routes/user-password-reset-routes.test.ts b/src/backend/tests/database/routes/user-password-reset-routes.test.ts new file mode 100644 index 00000000..4dca8edb --- /dev/null +++ b/src/backend/tests/database/routes/user-password-reset-routes.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthManager } from "../../../utils/auth-manager.js"; + +const calls = vi.hoisted(() => ({ + userUpdates: [] as Array<[string, Record]>, + deletedFor: [] as string[], + rotatedFor: [] as string[], + legacyWrapsDeletedFor: [] as string[], +})); + +function deletingRepo(label: string) { + return () => ({ + deleteByUserId: async (userId: string) => { + calls.deletedFor.push(`${label}:${userId}`); + return 0; + }, + }); +} + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + update: async (userId: string, update: Record) => { + calls.userUpdates.push([userId, update]); + return { id: userId }; + }, + }), + createCurrentSettingsRepository: () => ({}), + createCurrentSshCredentialUsageRepository: deletingRepo("usage"), + createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"), + createCurrentRecentActivityRepository: deletingRepo("activity"), + createCurrentDismissedAlertRepository: deletingRepo("alerts"), + createCurrentSnippetRepository: deletingRepo("snippets"), + createCurrentHostRepository: deletingRepo("hosts"), + createCurrentCredentialRepository: deletingRepo("credentials"), +})); + +vi.mock("../../../utils/user-keys.js", () => ({ + UserKeyManager: { + getInstance: () => ({ + rotateUserDEK: async (userId: string) => { + calls.rotatedFor.push(userId); + return Buffer.alloc(32); + }, + }), + }, +})); + +vi.mock("../../../utils/crypto-migration/dek-migration.js", () => ({ + deleteLegacyWraps: async (userId: string) => { + calls.legacyWrapsDeletedFor.push(userId); + }, +})); + +import { resetUserPassword } from "../../../database/routes/user-password-reset-routes.js"; + +function fakeAuthManager(unlocked: boolean): AuthManager { + return { + isUserUnlocked: () => unlocked, + logoutUser: vi.fn(async () => {}), + } as unknown as AuthManager; +} + +beforeEach(() => { + calls.userUpdates = []; + calls.deletedFor = []; + calls.rotatedFor = []; + calls.legacyWrapsDeletedFor = []; +}); + +describe("resetUserPassword", () => { + it("preserves data for users with a server-wrapped key", async () => { + const outcome = await resetUserPassword(fakeAuthManager(true), { + userId: "user-1", + username: "alice", + newPassword: "new-password", + confirmDataWipe: false, + }); + + expect(outcome).toEqual({ status: "reset", dataWiped: false }); + expect(calls.userUpdates).toHaveLength(1); + expect(calls.userUpdates[0][1]).toHaveProperty("passwordHash"); + expect(calls.deletedFor).toEqual([]); + expect(calls.rotatedFor).toEqual([]); + }); + + it("requires explicit consent before wiping an unmigrated user", async () => { + const outcome = await resetUserPassword(fakeAuthManager(false), { + userId: "user-1", + username: "alice", + newPassword: "new-password", + confirmDataWipe: false, + }); + + expect(outcome).toEqual({ status: "wipe_confirmation_required" }); + expect(calls.userUpdates).toEqual([]); + expect(calls.deletedFor).toEqual([]); + }); + + it("wipes data and rotates the key when consent is given", async () => { + const outcome = await resetUserPassword(fakeAuthManager(false), { + userId: "user-1", + username: "alice", + newPassword: "new-password", + confirmDataWipe: true, + }); + + expect(outcome).toEqual({ status: "reset", dataWiped: true }); + expect(calls.deletedFor).toEqual([ + "usage:user-1", + "bookmarks:user-1", + "activity:user-1", + "alerts:user-1", + "snippets:user-1", + "hosts:user-1", + "credentials:user-1", + ]); + expect(calls.rotatedFor).toEqual(["user-1"]); + expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]); + expect( + calls.userUpdates.some(([, update]) => update.totpEnabled === false), + ).toBe(true); + }); +}); diff --git a/src/backend/database/routes/user-totp-routes.test.ts b/src/backend/tests/database/routes/user-totp-routes.test.ts similarity index 75% rename from src/backend/database/routes/user-totp-routes.test.ts rename to src/backend/tests/database/routes/user-totp-routes.test.ts index 76d49d3b..58f53516 100644 --- a/src/backend/database/routes/user-totp-routes.test.ts +++ b/src/backend/tests/database/routes/user-totp-routes.test.ts @@ -2,19 +2,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import bcrypt from "bcryptjs"; import speakeasy from "speakeasy"; -// The route module imports the db barrel (which has filesystem/crypto side -// effects on import) plus the logger; stub both so importing stays inert. -const updateWhere = vi.fn().mockResolvedValue(undefined); -const updateSet = vi.fn(() => ({ where: updateWhere })); -const dbUpdate = vi.fn(() => ({ set: updateSet })); +// The route module imports repository factories and the logger; stub both so +// importing stays inert. +const userRepositoryUpdate = vi.fn().mockResolvedValue(null); -vi.mock("../db/index.js", () => ({ - db: { - update: dbUpdate, - }, +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + update: userRepositoryUpdate, + }), })); -vi.mock("../../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ authLogger: { debug: vi.fn(), info: vi.fn(), @@ -24,7 +22,8 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -const { verifyTotpReauth } = await import("./user-totp-routes.js"); +const { verifyTotpReauth } = + await import("../../../database/routes/user-totp-routes.js"); type AnyUser = Parameters[0]; @@ -47,8 +46,8 @@ describe("verifyTotpReauth", () => { vi.clearAllMocks(); }); - it("accepts the correct password", async () => { - expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(true); + it("does not accept the account password as a second factor", async () => { + expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(false); }); it("accepts a valid TOTP code without a password", async () => { @@ -59,14 +58,14 @@ describe("verifyTotpReauth", () => { it("accepts a valid backup code and consumes it", async () => { const result = await verifyTotpReauth(makeUser(), "BACKUP01"); expect(result).toBe(true); - expect(updateSet).toHaveBeenCalledWith({ + expect(userRepositoryUpdate).toHaveBeenCalledWith("user-1", { totpBackupCodes: JSON.stringify(["BACKUP02"]), }); }); it("rejects a wrong password / invalid code", async () => { expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false); - expect(updateSet).not.toHaveBeenCalled(); + expect(userRepositoryUpdate).not.toHaveBeenCalled(); }); it("ignores the password path for OIDC users but still accepts TOTP", async () => { diff --git a/src/backend/ssh/credential-username.test.ts b/src/backend/tests/hosts/credential-username.test.ts similarity index 63% rename from src/backend/ssh/credential-username.test.ts rename to src/backend/tests/hosts/credential-username.test.ts index fc6a56e4..daf76cf5 100644 --- a/src/backend/ssh/credential-username.test.ts +++ b/src/backend/tests/hosts/credential-username.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { pickResolvedUsername, + pickResolvedPassword, expandOidcUsername, -} from "./credential-username.js"; +} from "../../hosts/credential-username.js"; describe("pickResolvedUsername", () => { it("keeps the host username when one is set, even with a credential username", () => { @@ -30,6 +31,27 @@ describe("pickResolvedUsername", () => { }); }); +describe("pickResolvedPassword", () => { + it("keeps the host-specific password ahead of the credential password", () => { + expect(pickResolvedPassword("host-pass", "credential-pass")).toBe( + "host-pass", + ); + }); + + it("falls back to the credential password when the host has none", () => { + expect(pickResolvedPassword("", "credential-pass")).toBe("credential-pass"); + expect(pickResolvedPassword(undefined, "credential-pass")).toBe( + "credential-pass", + ); + }); + + it("treats whitespace-only passwords as empty", () => { + expect(pickResolvedPassword(" ", "credential-pass")).toBe( + "credential-pass", + ); + }); +}); + describe("expandOidcUsername", () => { beforeEach(() => { vi.resetModules(); @@ -41,56 +63,40 @@ describe("expandOidcUsername", () => { }); it("expands the placeholder with the user's OIDC identifier", async () => { - vi.doMock("../database/db/index.js", () => ({ - getDb: () => ({ - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => [{ oidcIdentifier: "jdoe" }], - }), - }), - }), + vi.doMock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async () => ({ oidcIdentifier: "jdoe" }), }), })); - vi.doMock("../database/db/schema.js", () => ({ users: {} })); - vi.doMock("drizzle-orm", () => ({ eq: vi.fn() })); const { expandOidcUsername: expand } = - await import("./credential-username.js"); + await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe"); }); it("leaves the placeholder as-is when the user has no OIDC identifier", async () => { - vi.doMock("../database/db/index.js", () => ({ - getDb: () => ({ - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => [{ oidcIdentifier: null }], - }), - }), - }), + vi.doMock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async () => ({ oidcIdentifier: null }), }), })); - vi.doMock("../database/db/schema.js", () => ({ users: {} })); - vi.doMock("drizzle-orm", () => ({ eq: vi.fn() })); const { expandOidcUsername: expand } = - await import("./credential-username.js"); + await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe( "$oidc.preferred_username", ); }); it("returns the username unchanged when the DB lookup throws", async () => { - vi.doMock("../database/db/index.js", () => ({ - getDb: () => { + vi.doMock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => { throw new Error("DB unavailable"); }, })); const { expandOidcUsername: expand } = - await import("./credential-username.js"); + await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe( "$oidc.preferred_username", ); diff --git a/src/backend/tests/hosts/file-manager/operation-commands.test.ts b/src/backend/tests/hosts/file-manager/operation-commands.test.ts new file mode 100644 index 00000000..472c2d24 --- /dev/null +++ b/src/backend/tests/hosts/file-manager/operation-commands.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { buildDeleteCommand } from "../../../hosts/file-manager/operation-commands.js"; + +describe("buildDeleteCommand", () => { + it("builds a PowerShell 5.1 compatible delete command for Windows files", () => { + const command = buildDeleteCommand( + "/C:/Users/Administrator/test.txt", + false, + ); + + expect(command.command).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop", + ); + expect(command.commandWithSuccess).toBe( + `${command.command}; if ($?) { Write-Output "SUCCESS" }`, + ); + expect(command.commandWithSuccess).not.toContain("&&"); + }); + + it("adds recursive deletion for Windows directories", () => { + const command = buildDeleteCommand("C:/Temp/Folder", true); + + expect(command.command).toBe( + "Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop", + ); + }); + + it("escapes single quotes in Windows literal paths", () => { + const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false); + + expect(command.command).toBe( + "Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop", + ); + }); + + it("keeps POSIX delete commands using shell success chaining", () => { + const command = buildDeleteCommand("/tmp/O'Brien.txt", false); + + expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'"); + expect(command.commandWithSuccess).toBe( + `${command.command} && echo "SUCCESS"`, + ); + }); +}); diff --git a/src/backend/ssh/file-manager-utils.test.ts b/src/backend/tests/hosts/file-manager/utils.test.ts similarity index 99% rename from src/backend/ssh/file-manager-utils.test.ts rename to src/backend/tests/hosts/file-manager/utils.test.ts index 790dbc17..1d4360e6 100644 --- a/src/backend/ssh/file-manager-utils.test.ts +++ b/src/backend/tests/hosts/file-manager/utils.test.ts @@ -6,7 +6,7 @@ import { getMimeType, detectBinary, parseLsDateToTimestamp, -} from "./file-manager-utils.js"; +} from "../../../hosts/file-manager/utils.js"; describe("isExecutableFile", () => { it("flags scripts with execute permission", () => { diff --git a/src/backend/guacamole/token-service.test.ts b/src/backend/tests/hosts/guacamole/token-service.test.ts similarity index 68% rename from src/backend/guacamole/token-service.test.ts rename to src/backend/tests/hosts/guacamole/token-service.test.ts index 6ad3110a..de3069a5 100644 --- a/src/backend/guacamole/token-service.test.ts +++ b/src/backend/tests/hosts/guacamole/token-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -vi.mock("../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ guacLogger: { debug: vi.fn(), info: vi.fn(), @@ -10,7 +10,8 @@ vi.mock("../utils/logger.js", () => ({ }, })); -const { GuacamoleTokenService } = await import("./token-service.js"); +const { GuacamoleTokenService } = + await import("../../../hosts/guacamole/token-service.js"); describe("GuacamoleTokenService", () => { const tokenService = GuacamoleTokenService.getInstance(); @@ -45,4 +46,23 @@ describe("GuacamoleTokenService", () => { }); expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined(); }); + + it("preserves recording metadata outside guacd connection settings", () => { + const recording = { + hostId: 42, + userId: "user-1", + protocol: "vnc" as const, + path: "recording.guac", + startedAt: "2026-07-14T00:00:00.000Z", + }; + const token = tokenService.createVncToken( + "vnc.example.test", + "user", + "secret", + {}, + recording, + ); + + expect(tokenService.decryptToken(token)?.recording).toEqual(recording); + }); }); diff --git a/src/backend/ssh/host-metrics-preferences.test.ts b/src/backend/tests/hosts/host-metrics-preferences.test.ts similarity index 98% rename from src/backend/ssh/host-metrics-preferences.test.ts rename to src/backend/tests/hosts/host-metrics-preferences.test.ts index 10b3d1d8..4fa559b9 100644 --- a/src/backend/ssh/host-metrics-preferences.test.ts +++ b/src/backend/tests/hosts/host-metrics-preferences.test.ts @@ -5,7 +5,7 @@ import { isMetricCardId, isManagerCardId, METRIC_CARD_IDS, -} from "../../types/host-metrics.js"; +} from "../../../types/host-metrics.js"; describe("deriveEnabledWidgets", () => { it("returns only metric ids, in canonical order, deduped", () => { diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts new file mode 100644 index 00000000..9729fd97 --- /dev/null +++ b/src/backend/tests/hosts/host-resolver.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + host: null as Record | null, + hasAccess: true, + isAdminBypass: false, + overrideCredentialId: null as number | null, + credentials: new Map>(), + sharedSecret: null as Record | null, + auditCalls: [] as Record[], +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + findHostOwnerId: async () => (state.host?.userId as string) ?? null, + findHostById: async () => (state.host ? { ...state.host } : null), + findOverrideCredentialId: async () => state.overrideCredentialId, + findCredentialByIdForUser: async (credentialId: number, userId: string) => + state.credentials.get(`${credentialId}:${userId}`) ?? null, + }), + createCurrentVaultProfileRepository: () => ({ + findById: async () => null, + }), + createCurrentUserRepository: () => ({ + findById: async (userId: string) => ({ id: userId, username: userId }), + }), +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (params: Record) => { + state.auditCalls.push(params); + }, +})); + +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + canAccessHost: async () => ({ + hasAccess: state.hasAccess, + isAdminBypass: state.isAdminBypass, + }), + }), + }, +})); + +vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ + getSecretForUser: async () => state.sharedSecret, + }), + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { resolveHostById } from "../../hosts/host-resolver.js"; + +function baseHost(overrides: Record = {}) { + return { + id: 42, + userId: "owner", + name: "prod", + ip: "10.0.0.42", + port: 22, + username: "root", + authType: "password", + password: "owner-secret", + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + vaultProfileId: null, + sudoPassword: "owner-sudo", + autostartPassword: "auto-pass", + autostartKey: null, + autostartKeyPassword: null, + jumpHosts: null, + tunnelConnections: null, + statsConfig: null, + terminalConfig: null, + socks5ProxyChain: null, + quickActions: null, + overrideCredentialUsername: false, + ...overrides, + }; +} + +beforeEach(() => { + state.host = baseHost(); + state.hasAccess = true; + state.isAdminBypass = false; + state.overrideCredentialId = null; + state.credentials.clear(); + state.sharedSecret = null; + state.auditCalls = []; +}); + +describe("resolveHostById", () => { + it("returns null when access is denied", async () => { + state.hasAccess = false; + expect(await resolveHostById(42, "stranger")).toBeNull(); + }); + + it("resolves the owner's credential on the owner path", async () => { + // Empty host username so the credential's username is used as fallback. + state.host = baseHost({ + authType: "credential", + credentialId: 9, + username: "", + }); + state.credentials.set("9:owner", { + id: 9, + username: "cred-user", + authType: "key", + password: null, + privateKey: "PRIVATE-KEY", + key: null, + keyPassword: "kp", + keyType: "ssh-ed25519", + certPublicKey: null, + }); + + const host = (await resolveHostById(42, "owner")) as Record< + string, + unknown + >; + expect(host.key).toBe("PRIVATE-KEY"); + expect(host.username).toBe("cred-user"); + expect(host.authType).toBe("key"); + expect(host.sudoPassword).toBe("owner-sudo"); + }); + + it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => { + state.host = baseHost({ username: "" }); + state.sharedSecret = { + username: "shared-user", + authType: "password", + password: "shared-pass", + }; + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.password).toBe("shared-pass"); + expect(host.username).toBe("shared-user"); + expect(host.sudoPassword).toBeNull(); + expect(host.autostartPassword).toBeNull(); + }); + + it("prefers the recipient's override credential over the snapshot", async () => { + state.host = baseHost({ username: "" }); + state.overrideCredentialId = 5; + state.credentials.set("5:recipient", { + id: 5, + username: "my-user", + authType: "password", + password: "my-pass", + privateKey: null, + key: null, + keyPassword: null, + keyType: null, + }); + state.sharedSecret = { + username: "shared-user", + authType: "password", + password: "shared-pass", + }; + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.password).toBe("my-pass"); + expect(host.username).toBe("my-user"); + }); + + it("denies a non-owner when a secret-bearing host has no snapshot", async () => { + expect(await resolveHostById(42, "recipient")).toBeNull(); + }); + + it("lets a non-owner through on secret-less auth types without a snapshot", async () => { + state.host = baseHost({ authType: "none", password: null }); + const host = await resolveHostById(42, "recipient"); + expect(host).not.toBeNull(); + }); + + it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => { + state.isAdminBypass = true; + state.host = baseHost({ + authType: "credential", + credentialId: 9, + username: "", + password: null, + }); + state.credentials.set("9:owner", { + id: 9, + username: "cred-user", + authType: "key", + password: null, + privateKey: "OWNER-PRIVATE-KEY", + key: null, + keyPassword: "kp", + keyType: "ssh-ed25519", + certPublicKey: null, + }); + + const host = (await resolveHostById(42, "adminUser")) as Record< + string, + unknown + >; + // Owner credential resolved (not the share snapshot path). + expect(host.key).toBe("OWNER-PRIVATE-KEY"); + expect(host.username).toBe("cred-user"); + // Owner-only operational secrets are NOT stripped for the admin. + expect(host.sudoPassword).toBe("owner-sudo"); + expect(host.autostartPassword).toBe("auto-pass"); + }); + + it("audits every admin-bypass host resolution", async () => { + state.isAdminBypass = true; + await resolveHostById(42, "adminUser"); + expect(state.auditCalls).toHaveLength(1); + expect(state.auditCalls[0]).toMatchObject({ + action: "admin_connect_host", + resourceType: "host", + resourceId: "42", + userId: "adminUser", + }); + }); + + it("does not audit an ordinary owner resolution", async () => { + await resolveHostById(42, "owner"); + expect(state.auditCalls).toHaveLength(0); + }); +}); diff --git a/src/backend/ssh/managers/managers.test.ts b/src/backend/tests/hosts/managers/managers.test.ts similarity index 92% rename from src/backend/ssh/managers/managers.test.ts rename to src/backend/tests/hosts/managers/managers.test.ts index 703076a1..fa598ae7 100644 --- a/src/backend/ssh/managers/managers.test.ts +++ b/src/backend/tests/hosts/managers/managers.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; -import { buildSudoCommand, shellSingleQuote } from "./exec-elevated.js"; -import { parsePlatformProbe } from "./platform.js"; +import { + buildSudoCommand, + shellSingleQuote, +} from "../../../hosts/metrics/managers/exec-elevated.js"; +import { parsePlatformProbe } from "../../../hosts/metrics/managers/platform.js"; import { isValidSystemdUnit, isValidPid, @@ -12,32 +15,53 @@ import { isValidSignal, isValidServiceAction, isAllowedPath, -} from "./validation.js"; -import { parseServiceList, buildServiceActionCommand } from "./services.js"; -import { parseProcessList, buildKillCommand } from "./processes.js"; -import { parseDfMounts, parseTopMemory } from "./simple-reads.js"; +} from "../../../hosts/metrics/managers/validation.js"; +import { + parseServiceList, + buildServiceActionCommand, +} from "../../../hosts/metrics/managers/services.js"; +import { + parseProcessList, + buildKillCommand, +} from "../../../hosts/metrics/managers/processes.js"; +import { + parseDfMounts, + parseTopMemory, +} from "../../../hosts/metrics/managers/simple-reads.js"; import { parseCrontab, serializeCrontab, isValidCronSchedule, buildApplyCrontabCommand, -} from "./cron.js"; +} from "../../../hosts/metrics/managers/cron.js"; import { buildPackageActionCommand, parseUpgradable, buildListUpgradableCommand, -} from "./packages.js"; +} from "../../../hosts/metrics/managers/packages.js"; import { buildIssueCommand, buildRenewCommand, buildRevokeCommand, isValidCertName, parseCertbotCertificates, -} from "./ssl.js"; -import { buildIptablesRuleCommand, buildNftRuleCommand } from "./firewall.js"; -import { parsePasswd, parseSudoers } from "./users.js"; -import { buildHealthCheckCommand, parseHealthResult } from "./health.js"; -import { buildTailCommand, clampLines } from "./logs.js"; +} from "../../../hosts/metrics/managers/ssl.js"; +import { + buildIptablesRuleCommand, + buildNftRuleCommand, +} from "../../../hosts/metrics/managers/firewall.js"; +import { + parsePasswd, + parseSudoers, +} from "../../../hosts/metrics/managers/users.js"; +import { + buildHealthCheckCommand, + parseHealthResult, +} from "../../../hosts/metrics/managers/health.js"; +import { + buildTailCommand, + clampLines, +} from "../../../hosts/metrics/managers/logs.js"; describe("exec-elevated", () => { it("single-quotes and escapes for the shell", () => { diff --git a/src/backend/ssh/host-metrics-helpers.test.ts b/src/backend/tests/hosts/metrics/helpers.test.ts similarity index 50% rename from src/backend/ssh/host-metrics-helpers.test.ts rename to src/backend/tests/hosts/metrics/helpers.test.ts index 70714d9f..1fd0e280 100644 --- a/src/backend/ssh/host-metrics-helpers.test.ts +++ b/src/backend/tests/hosts/metrics/helpers.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect } from "vitest"; +import type { Client } from "ssh2"; +import { describe, it, expect, vi } from "vitest"; import { supportsMetrics, isTcpPingEnabled, - createConnectionLog, -} from "./host-metrics-helpers.js"; + tcpPingThroughJumpHost, +} from "../../../hosts/metrics/helpers.js"; +import { createConnectionLog } from "../../../hosts/connection-log.js"; describe("supportsMetrics", () => { it("supports plain ssh hosts", () => { @@ -66,3 +68,57 @@ describe("createConnectionLog", () => { expect("timestamp" in entry).toBe(false); }); }); + +describe("tcpPingThroughJumpHost", () => { + it("reports the final destination online when forwarding succeeds", async () => { + const stream = { destroy: vi.fn() }; + const jumpClient = { + end: vi.fn(), + forwardOut: vi.fn((_src, _srcPort, host, port, callback) => { + expect(host).toBe("private.example"); + expect(port).toBe(22); + callback(undefined, stream); + }), + } as unknown as Pick; + + await expect( + tcpPingThroughJumpHost(jumpClient, "private.example", 22), + ).resolves.toBe(true); + expect(stream.destroy).toHaveBeenCalledOnce(); + expect(jumpClient.end).toHaveBeenCalledOnce(); + }); + + it("reports the final destination offline when forwarding fails", async () => { + const jumpClient = { + end: vi.fn(), + forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => { + callback(new Error("Connection refused")); + }), + } as unknown as Pick; + + await expect( + tcpPingThroughJumpHost(jumpClient, "private.example", 22), + ).resolves.toBe(false); + expect(jumpClient.end).toHaveBeenCalledOnce(); + }); + + it("reports the final destination offline when forwarding times out", async () => { + vi.useFakeTimers(); + const jumpClient = { + end: vi.fn(), + forwardOut: vi.fn(), + } as unknown as Pick; + + const result = tcpPingThroughJumpHost( + jumpClient, + "private.example", + 22, + 5000, + ); + await vi.advanceTimersByTimeAsync(5000); + + await expect(result).resolves.toBe(false); + expect(jumpClient.end).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); +}); diff --git a/src/backend/ssh/managers/exec-elevated.test.ts b/src/backend/tests/hosts/metrics/managers/exec-elevated.test.ts similarity index 95% rename from src/backend/ssh/managers/exec-elevated.test.ts rename to src/backend/tests/hosts/metrics/managers/exec-elevated.test.ts index dbe992c0..bed2bf2e 100644 --- a/src/backend/ssh/managers/exec-elevated.test.ts +++ b/src/backend/tests/hosts/metrics/managers/exec-elevated.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../widgets/common-utils.js", () => ({ +vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({ execCommand: (...args: unknown[]) => execCommand(...args), })); -import { execElevated, ElevationError } from "./exec-elevated.js"; +import { + execElevated, + ElevationError, +} from "../../../../hosts/metrics/managers/exec-elevated.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics/state.test.ts b/src/backend/tests/hosts/metrics/state.test.ts new file mode 100644 index 00000000..64068089 --- /dev/null +++ b/src/backend/tests/hosts/metrics/state.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ConcurrentLimiter, + HostPollCache, +} from "../../../hosts/metrics/state.js"; + +describe("ConcurrentLimiter", () => { + it("never exceeds max concurrent runners", async () => { + const limiter = new ConcurrentLimiter(2); + let peak = 0; + let current = 0; + + const job = async () => { + current += 1; + peak = Math.max(peak, current); + await new Promise((r) => setTimeout(r, 30)); + current -= 1; + }; + + await Promise.all([ + limiter.run(job), + limiter.run(job), + limiter.run(job), + limiter.run(job), + ]); + + expect(peak).toBeLessThanOrEqual(2); + expect(limiter.activeCount).toBe(0); + expect(limiter.pendingCount).toBe(0); + }); + + it("runs waiters in FIFO order after a slot frees", async () => { + const limiter = new ConcurrentLimiter(1); + const order: number[] = []; + + const first = limiter.run(async () => { + order.push(1); + await new Promise((r) => setTimeout(r, 20)); + }); + const second = limiter.run(async () => { + order.push(2); + }); + const third = limiter.run(async () => { + order.push(3); + }); + + await Promise.all([first, second, third]); + expect(order).toEqual([1, 2, 3]); + }); + + it("rejects invalid maxConcurrent", () => { + expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/); + }); +}); + +describe("HostPollCache", () => { + it("returns cached host within TTL for the same user", () => { + const cache = new HostPollCache<{ id: number; name: string }>(60_000); + cache.set(1, "user-a", { id: 1, name: "alpha" }); + expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" }); + expect(cache.get(1, "user-b")).toBeNull(); + }); + + it("expires entries after TTL", () => { + vi.useFakeTimers(); + const cache = new HostPollCache<{ id: number }>(1_000); + cache.set(7, "u", { id: 7 }); + expect(cache.get(7, "u")).toEqual({ id: 7 }); + vi.advanceTimersByTime(1_001); + expect(cache.get(7, "u")).toBeNull(); + vi.useRealTimers(); + }); + + it("invalidate drops a host or the whole cache", () => { + const cache = new HostPollCache<{ id: number }>(60_000); + cache.set(1, "u", { id: 1 }); + cache.set(2, "u", { id: 2 }); + cache.invalidate(1); + expect(cache.get(1, "u")).toBeNull(); + expect(cache.get(2, "u")).toEqual({ id: 2 }); + cache.invalidate(); + expect(cache.get(2, "u")).toBeNull(); + }); +}); diff --git a/src/backend/ssh/widgets/common-utils.test.ts b/src/backend/tests/hosts/metrics/widgets/common-utils.test.ts similarity index 89% rename from src/backend/ssh/widgets/common-utils.test.ts rename to src/backend/tests/hosts/metrics/widgets/common-utils.test.ts index f448b781..ed64a916 100644 --- a/src/backend/ssh/widgets/common-utils.test.ts +++ b/src/backend/tests/hosts/metrics/widgets/common-utils.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { toFixedNum, kibToGiB } from "./common-utils.js"; +import { + toFixedNum, + kibToGiB, +} from "../../../../hosts/metrics/widgets/common-utils.js"; describe("toFixedNum", () => { it("rounds to the requested digit count", () => { diff --git a/src/backend/ssh/widgets/cpu-collector.test.ts b/src/backend/tests/hosts/metrics/widgets/cpu-collector.test.ts similarity index 92% rename from src/backend/ssh/widgets/cpu-collector.test.ts rename to src/backend/tests/hosts/metrics/widgets/cpu-collector.test.ts index 5af1d5a7..c4907d98 100644 --- a/src/backend/ssh/widgets/cpu-collector.test.ts +++ b/src/backend/tests/hosts/metrics/widgets/cpu-collector.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseCpuLine } from "./cpu-collector.js"; +import { parseCpuLine } from "../../../../hosts/metrics/widgets/cpu-collector.js"; describe("parseCpuLine", () => { it("parses a standard /proc/stat cpu line", () => { diff --git a/src/backend/ssh/widgets/temperature-collector.test.ts b/src/backend/tests/hosts/metrics/widgets/temperature-collector.test.ts similarity index 92% rename from src/backend/ssh/widgets/temperature-collector.test.ts rename to src/backend/tests/hosts/metrics/widgets/temperature-collector.test.ts index 49b0cf95..36757871 100644 --- a/src/backend/ssh/widgets/temperature-collector.test.ts +++ b/src/backend/tests/hosts/metrics/widgets/temperature-collector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseSensorsOutput, parseSysfsThermalOutput, -} from "./temperature-collector.js"; +} from "../../../../hosts/metrics/widgets/temperature-collector.js"; describe("temperature collectors", () => { it("parses sysfs thermal zone output", () => { diff --git a/src/backend/tests/hosts/ssh-dns.test.ts b/src/backend/tests/hosts/ssh-dns.test.ts new file mode 100644 index 00000000..c961d607 --- /dev/null +++ b/src/backend/tests/hosts/ssh-dns.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isRetriableDnsError, + resolveHostForSshConnect, + resolveSshConnectConfigHost, + shouldResolveBeforeSshConnect, +} from "../../hosts/ssh-dns.js"; + +describe("SSH DNS resolution", () => { + it("retries transient EAI_AGAIN errors before returning an address", async () => { + const lookup = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error("try again"), { code: "EAI_AGAIN" }), + ) + .mockResolvedValueOnce({ address: "10.0.0.5", family: 4 }); + const wait = vi.fn().mockResolvedValue(undefined); + + await expect( + resolveHostForSshConnect("alp", lookup, [10], wait), + ).resolves.toEqual({ + host: "10.0.0.5", + resolvedAddress: "10.0.0.5", + attempts: 2, + }); + expect(wait).toHaveBeenCalledWith(10); + }); + + it("does not retry permanent DNS failures", async () => { + const error = Object.assign(new Error("not found"), { code: "ENOTFOUND" }); + const lookup = vi.fn().mockRejectedValue(error); + const wait = vi.fn().mockResolvedValue(undefined); + + await expect( + resolveHostForSshConnect("missing", lookup, [10], wait), + ).rejects.toBe(error); + expect(wait).not.toHaveBeenCalled(); + }); + + it("skips DNS lookup for literal IP addresses", async () => { + const lookup = vi.fn(); + + await expect( + resolveHostForSshConnect("192.0.2.1", lookup), + ).resolves.toEqual({ + host: "192.0.2.1", + attempts: 0, + }); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("detects retryable DNS errors by code or message", () => { + expect(isRetriableDnsError({ code: "EAI_AGAIN" })).toBe(true); + expect(isRetriableDnsError(new Error("getaddrinfo EAI_AGAIN alp"))).toBe( + true, + ); + expect(isRetriableDnsError({ code: "ENOTFOUND" })).toBe(false); + }); + + it("only pre-resolves hostnames", () => { + expect(shouldResolveBeforeSshConnect("alp")).toBe(true); + expect(shouldResolveBeforeSshConnect("127.0.0.1")).toBe(false); + expect(shouldResolveBeforeSshConnect("[2001:db8::1]")).toBe(false); + }); + + it("updates SSH connect config hosts in place", async () => { + const lookup = vi + .fn() + .mockResolvedValue({ address: "10.0.0.6", family: 4 }); + const config = { host: "alp", port: 22 }; + + await expect(resolveSshConnectConfigHost(config, lookup)).resolves.toEqual({ + host: "10.0.0.6", + port: 22, + originalHost: "alp", + resolvedHost: "10.0.0.6", + }); + }); +}); diff --git a/src/backend/ssh/terminal-agent-auth.test.ts b/src/backend/tests/hosts/terminal-agent-auth.test.ts similarity index 74% rename from src/backend/ssh/terminal-agent-auth.test.ts rename to src/backend/tests/hosts/terminal-agent-auth.test.ts index 2ac0414d..8e5587f5 100644 --- a/src/backend/ssh/terminal-agent-auth.test.ts +++ b/src/backend/tests/hosts/terminal-agent-auth.test.ts @@ -6,7 +6,7 @@ vi.mock("fs/promises", () => ({ access: mockAccess, })); -import { applyAgentAuth, resolveAgentSocket } from "./terminal-auth-helpers.js"; +import { resolveAgentSocket } from "../../hosts/terminal-auth-helpers.js"; describe("resolveAgentSocket", () => { const originalEnv = process.env.SSH_AUTH_SOCK; @@ -63,25 +63,6 @@ describe("resolveAgentSocket", () => { expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.789" }); }); - it("reads agent socket path from serialized terminal config", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); - mockAccess.mockResolvedValue(undefined); - - const result = await resolveAgentSocket( - JSON.stringify({ agentSocketPath: "/tmp/serialized-agent.sock" }), - ); - - expect(result).toEqual({ socketPath: "/tmp/serialized-agent.sock" }); - }); - - it("returns error for invalid serialized terminal config", async () => { - const result = await resolveAgentSocket("{"); - - expect(result).toEqual({ - error: "Invalid terminal configuration for SSH agent auth.", - }); - }); - it("returns error when neither SSH_AUTH_SOCK nor explicit path is set", async () => { const result = await resolveAgentSocket({}); @@ -119,17 +100,4 @@ describe("resolveAgentSocket", () => { }); expect(mockAccess).not.toHaveBeenCalled(); }); - - it("attaches an ssh2 agent to a connect config", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); - mockAccess.mockResolvedValue(undefined); - const config: Record = {}; - - const result = await applyAgentAuth(config, { - agentSocketPath: "/tmp/ssh-agent.sock", - }); - - expect(result).toEqual({ socketPath: "/tmp/ssh-agent.sock" }); - expect(config.agent).toBeDefined(); - }); }); diff --git a/src/backend/tests/hosts/terminal-port-knocking.test.ts b/src/backend/tests/hosts/terminal-port-knocking.test.ts new file mode 100644 index 00000000..4f26659d --- /dev/null +++ b/src/backend/tests/hosts/terminal-port-knocking.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "events"; +import { describe, expect, it, vi } from "vitest"; +import { performPortKnocking } from "../../hosts/terminal-auth-helpers.js"; + +class FakeTcpSocket extends EventEmitter { + readonly connect = vi.fn(); + readonly destroy = vi.fn(); +} + +describe("performPortKnocking", () => { + it("continues through TCP knock errors", async () => { + const first = new FakeTcpSocket(); + const second = new FakeTcpSocket(); + const wait = vi.fn().mockResolvedValue(undefined); + + const knocking = performPortKnocking( + "192.0.2.10", + [ + { port: 1111, protocol: "tcp", delay: 10 }, + { port: 2222, protocol: "tcp", delay: 0 }, + ], + { + createTcpSocket: vi + .fn() + .mockReturnValueOnce(first) + .mockReturnValueOnce(second), + wait, + }, + ); + + first.emit("error", new Error("closed")); + await Promise.resolve(); + second.emit("connect"); + await knocking; + + expect(first.connect).toHaveBeenCalledWith(1111, "192.0.2.10"); + expect(second.connect).toHaveBeenCalledWith(2222, "192.0.2.10"); + expect(first.destroy).toHaveBeenCalled(); + expect(second.destroy).toHaveBeenCalled(); + expect(wait).toHaveBeenCalledWith(10); + }); + + it("times out TCP knocks that are silently dropped", async () => { + const socket = new FakeTcpSocket(); + const wait = vi.fn().mockResolvedValue(undefined); + + await performPortKnocking("192.0.2.10", [{ port: 1111, delay: 0 }], { + createTcpSocket: () => socket as never, + tcpTimeoutMs: 1, + wait, + }); + + expect(socket.connect).toHaveBeenCalledWith(1111, "192.0.2.10"); + expect(socket.destroy).toHaveBeenCalled(); + expect(wait).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/ssh/terminal-session-manager.test.ts b/src/backend/tests/hosts/terminal/session-manager.test.ts similarity index 79% rename from src/backend/ssh/terminal-session-manager.test.ts rename to src/backend/tests/hosts/terminal/session-manager.test.ts index 8b48136a..9a369716 100644 --- a/src/backend/ssh/terminal-session-manager.test.ts +++ b/src/backend/tests/hosts/terminal/session-manager.test.ts @@ -1,23 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Stub all external imports before loading the module under test -const mockInsertValues = vi.fn().mockResolvedValue(undefined); -const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }); +const mockCreate = vi.fn().mockResolvedValue({ id: 1 }); +const mockUpdateEnded = vi.fn().mockResolvedValue(undefined); -vi.mock("../database/db/index.js", () => ({ - getDb: () => ({ - $client: { - prepare: () => ({ get: () => undefined }), - }, - insert: mockInsert, +vi.mock("../../../database/db/index.js", () => ({ + getDb: () => ({}), +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSessionRecordingRepository: () => ({ + create: mockCreate, + updateEnded: mockUpdateEnded, }), })); -vi.mock("../database/db/schema.js", () => ({ - sessionRecordings: {}, -})); - -vi.mock("../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ sshLogger: { info: vi.fn(), warn: vi.fn(), @@ -29,25 +27,30 @@ vi.mock("../utils/logger.js", () => ({ // Mock individual fs.promises methods via a stub object const mockMkdir = vi.fn().mockResolvedValue(undefined); const mockWriteFile = vi.fn().mockResolvedValue(undefined); +const mockAppendFile = vi.fn().mockResolvedValue(undefined); +const mockUnlink = vi.fn().mockResolvedValue(undefined); vi.mock("fs", () => ({ default: { promises: { mkdir: mockMkdir, writeFile: mockWriteFile, + appendFile: mockAppendFile, readFile: vi.fn(), - unlink: vi.fn(), + unlink: mockUnlink, }, }, promises: { mkdir: mockMkdir, writeFile: mockWriteFile, + appendFile: mockAppendFile, readFile: vi.fn(), - unlink: vi.fn(), + unlink: mockUnlink, }, })); -const { sessionManager } = await import("./terminal-session-manager.js"); +const { sessionManager } = + await import("../../../hosts/terminal/session-manager.js"); describe("TerminalSessionManager - session logging", () => { beforeEach(() => { @@ -55,8 +58,8 @@ describe("TerminalSessionManager - session logging", () => { // Re-apply resolved values after clearAllMocks mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - mockInsertValues.mockResolvedValue(undefined); - mockInsert.mockReturnValue({ values: mockInsertValues }); + mockCreate.mockResolvedValue({ id: 1 }); + mockUpdateEnded.mockResolvedValue(undefined); }); it("createSession stores sessionLoggingEnabled=true by default", () => { @@ -111,8 +114,7 @@ describe("TerminalSessionManager - session logging", () => { sessionManager.destroySession(id); await new Promise((r) => setTimeout(r, 20)); expect(mockWriteFile).toHaveBeenCalledOnce(); - expect(mockInsert).toHaveBeenCalledOnce(); - expect(mockInsertValues).toHaveBeenCalledOnce(); + expect(mockCreate).toHaveBeenCalledOnce(); }); it("does not write log file when buffer is empty", async () => { diff --git a/src/backend/tests/hosts/tmux/helper.test.ts b/src/backend/tests/hosts/tmux/helper.test.ts new file mode 100644 index 00000000..ccd60ac0 --- /dev/null +++ b/src/backend/tests/hosts/tmux/helper.test.ts @@ -0,0 +1,56 @@ +import { EventEmitter } from "node:events"; +import type { Client } from "ssh2"; +import { describe, expect, it } from "vitest"; +import { + detectTmux, + tmuxCommand, + withTmuxPath, +} from "../../../hosts/tmux/helper.js"; + +describe("tmux command path handling", () => { + it("adds common non-login shell tmux paths", () => { + const command = withTmuxPath("command -v tmux"); + + expect(command).toMatch(/^\/bin\/sh -c '/); + expect(command).toContain("/opt/homebrew/bin"); + expect(command).toContain("/usr/local/bin"); + expect(command).toContain("/opt/bin"); + expect(command).toContain("/usr/pkg/bin"); + expect(command).toContain(":$PATH; export PATH; command -v tmux"); + }); + + it("wraps tmux invocations with the same path", () => { + expect(tmuxCommand("list-sessions")).toMatch( + /^\/bin\/sh -c 'PATH=.*:\$PATH; export PATH; tmux list-sessions'$/, + ); + }); + + it("detects suffixed tmux versions without parsing the version number", async () => { + const commands: string[] = []; + const conn = { + exec(command: string, callback: (error: null, stream: never) => void) { + commands.push(command); + const stream = new EventEmitter() as EventEmitter & { + stderr: EventEmitter; + }; + stream.stderr = new EventEmitter(); + callback(null, stream as never); + + queueMicrotask(() => { + if (commands.length === 1) { + stream.emit("data", Buffer.from("tmux 3.7b\n")); + stream.emit("close", 0); + return; + } + stream.emit("close", 1); + }); + }, + } as unknown as Client; + + await expect(detectTmux(conn)).resolves.toEqual({ + available: true, + sessions: [], + }); + expect(commands[0]).toContain("tmux -V"); + }); +}); diff --git a/src/backend/ssh/tmux-monitor-helpers.test.ts b/src/backend/tests/hosts/tmux/monitor-helpers.test.ts similarity index 99% rename from src/backend/ssh/tmux-monitor-helpers.test.ts rename to src/backend/tests/hosts/tmux/monitor-helpers.test.ts index 9e47eec9..a40059d5 100644 --- a/src/backend/ssh/tmux-monitor-helpers.test.ts +++ b/src/backend/tests/hosts/tmux/monitor-helpers.test.ts @@ -9,7 +9,7 @@ import { buildPaneMetrics, attachPanesToWindows, shellEscape, -} from "./tmux-monitor-helpers.js"; +} from "../../../hosts/tmux/monitor-helpers.js"; function join(...fields: (string | number)[]): string { return fields.join(SEP); diff --git a/src/backend/ssh/vault-signer-core.test.ts b/src/backend/tests/hosts/vault-signer-core.test.ts similarity index 99% rename from src/backend/ssh/vault-signer-core.test.ts rename to src/backend/tests/hosts/vault-signer-core.test.ts index 9bdb9f08..398750dd 100644 --- a/src/backend/ssh/vault-signer-core.test.ts +++ b/src/backend/tests/hosts/vault-signer-core.test.ts @@ -11,7 +11,7 @@ import { completeVaultOidc, signWithVault, type VaultProfileConfig, -} from "./vault-signer-core.js"; +} from "../../hosts/vault-signer-core.js"; const { utils: ssh2Utils } = ssh2Pkg; diff --git a/src/backend/tests/utils/audit-logger.test.ts b/src/backend/tests/utils/audit-logger.test.ts new file mode 100644 index 00000000..b2c92171 --- /dev/null +++ b/src/backend/tests/utils/audit-logger.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const createMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentAuditLogRepository: vi.fn(() => ({ + create: createMock, + })), +})); + +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; + +describe("logAudit", () => { + beforeEach(() => { + vi.clearAllMocks(); + createMock.mockResolvedValue(undefined); + }); + + it("inserts an audit log entry with all required fields", async () => { + const params = { + userId: "user-1", + username: "alice", + action: "create_host", + resourceType: "host", + resourceId: "42", + resourceName: "my-server", + ipAddress: "1.2.3.4", + userAgent: "Mozilla/5.0", + success: true, + }; + + await logAudit(params); + + expect(createMock).toHaveBeenCalledOnce(); + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + username: "alice", + action: "create_host", + resourceType: "host", + resourceId: "42", + resourceName: "my-server", + success: true, + }), + ); + }); + + it("does not throw when insert fails", async () => { + createMock.mockRejectedValue(new Error("db error")); + + await expect( + logAudit({ + userId: "u", + username: "u", + action: "x", + resourceType: "y", + success: false, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe("getRequestMeta", () => { + it("extracts ip from x-forwarded-for header", () => { + const req = { + headers: { + "x-forwarded-for": "10.0.0.1, 10.0.0.2", + "user-agent": "TestAgent/1.0", + }, + ip: "127.0.0.1", + }; + const meta = getRequestMeta(req as never); + expect(meta.ipAddress).toBe("10.0.0.1"); + expect(meta.userAgent).toBe("TestAgent/1.0"); + }); + + it("falls back to req.ip when no forwarded header", () => { + const req = { + headers: { "user-agent": "Bot/2" }, + ip: "192.168.1.1", + }; + const meta = getRequestMeta(req as never); + expect(meta.ipAddress).toBe("192.168.1.1"); + }); +}); diff --git a/src/backend/tests/utils/auth-manager-impersonation.test.ts b/src/backend/tests/utils/auth-manager-impersonation.test.ts new file mode 100644 index 00000000..a18bb1a6 --- /dev/null +++ b/src/backend/tests/utils/auth-manager-impersonation.test.ts @@ -0,0 +1,219 @@ +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const jwtSecret = "a".repeat(64); +const encryptionKey = crypto.randomBytes(32); + +const state = vi.hoisted(() => ({ + users: new Map(), + unlockedUsers: new Set(), + auditCalls: [] as Record[], +})); + +vi.mock("../../database/db/index.js", () => ({ + db: {}, + getDb: () => ({}), + saveMemoryDatabaseToFile: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ get: async () => null }), + // No sessionId in our tokens, so the session branch is skipped. + createCurrentSessionRepository: () => ({ findById: async () => null }), + createCurrentUserRepository: () => ({ + findById: async (userId: string) => state.users.get(userId) ?? null, + }), + createCurrentApiKeyRepository: () => ({}), + createCurrentTrustedDeviceRepository: () => ({}), + getCurrentSettingValue: () => null, +})); + +vi.mock("../../utils/user-keys.js", () => ({ + UserKeyManager: { + getInstance: () => ({ + hasUserDEK: vi.fn(() => true), + tryGetUserDEK: vi.fn(() => null), + invalidate: vi.fn(), + }), + }, +})); + +vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({ + adoptRecoveredDEK: vi.fn(async () => {}), + migratePasswordUserAtLogin: vi.fn(async () => true), +})); + +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getJWTSecret: async () => jwtSecret, + getEncryptionKey: async () => encryptionKey, + }), + }, +})); + +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { + canUserAccessData: (userId: string) => state.unlockedUsers.has(userId), + }, +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (params: Record) => { + state.auditCalls.push(params); + }, + getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), +})); + +const { AuthManager } = await import("../../utils/auth-manager.js"); +const authManager = AuthManager.getInstance(); +const middleware = authManager.createAuthMiddleware(); + +type MockRes = { + statusCode: number | null; + body: unknown; + status: (code: number) => MockRes; + json: (payload: unknown) => MockRes; + clearCookie: () => MockRes; +}; + +function makeRes(): MockRes { + const res: MockRes = { + statusCode: null, + body: null, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + clearCookie() { + return res; + }, + }; + return res; +} + +function runMiddleware(token: string, headers: Record = {}) { + const req = { + cookies: { jwt: token }, + headers, + method: "GET", + originalUrl: headers["__url"] ?? "/host/db/host", + url: headers["__url"] ?? "/host/db/host", + secure: false, + } as unknown as Parameters[0]; + const res = makeRes(); + let nexted = false; + return new Promise<{ req: typeof req; res: MockRes; nexted: boolean }>( + (resolve) => { + const next = () => { + nexted = true; + resolve({ req, res, nexted }); + }; + const maybe = middleware( + req, + res as unknown as Parameters[1], + next, + ); + Promise.resolve(maybe).then(() => { + if (!nexted) resolve({ req, res, nexted }); + }); + }, + ); +} + +function token(userId: string) { + return jwt.sign({ userId }, jwtSecret, { expiresIn: "1h" }); +} + +beforeEach(() => { + state.users = new Map([ + ["admin1", { id: "admin1", isAdmin: true, username: "admin" }], + ["target1", { id: "target1", isAdmin: false, username: "target" }], + ["regular1", { id: "regular1", isAdmin: false, username: "regular" }], + ]); + state.unlockedUsers = new Set(["admin1", "target1", "regular1"]); + state.auditCalls = []; +}); + +describe("AuthManager admin impersonation", () => { + it("swaps req.userId to the target for an admin on an allowlisted path", async () => { + const { req, nexted } = await runMiddleware(token("admin1"), { + "x-admin-target-user": "target1", + __url: "/host/db/host", + }); + expect(nexted).toBe(true); + expect((req as unknown as { userId: string }).userId).toBe("target1"); + expect( + (req as unknown as { actingAdminUserId?: string }).actingAdminUserId, + ).toBe("admin1"); + expect(state.auditCalls).toHaveLength(1); + expect(state.auditCalls[0]).toMatchObject({ + action: "admin_impersonated_request", + resourceId: "target1", + userId: "admin1", + }); + }); + + it("rejects impersonation by a non-admin", async () => { + const { res, nexted } = await runMiddleware(token("regular1"), { + "x-admin-target-user": "target1", + __url: "/host/db/host", + }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(403); + expect((res.body as { code?: string }).code).toBe("IMPERSONATION_DENIED"); + }); + + it("rejects impersonation on a non-allowlisted path", async () => { + const { res, nexted } = await runMiddleware(token("admin1"), { + "x-admin-target-user": "target1", + __url: "/users/sessions", + }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(403); + expect((res.body as { code?: string }).code).toBe( + "IMPERSONATION_NOT_ALLOWED", + ); + }); + + it("returns 423 when the target's data is locked", async () => { + state.unlockedUsers = new Set(["admin1"]); + const { res, nexted } = await runMiddleware(token("admin1"), { + "x-admin-target-user": "target1", + __url: "/host/db/host", + }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(423); + expect((res.body as { code?: string }).code).toBe("TARGET_DATA_LOCKED"); + }); + + it("404s when the target user does not exist", async () => { + const { res, nexted } = await runMiddleware(token("admin1"), { + "x-admin-target-user": "ghost", + __url: "/host/db/host", + }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(404); + }); + + it("ignores the header when the target equals the admin", async () => { + const { req, nexted } = await runMiddleware(token("admin1"), { + "x-admin-target-user": "admin1", + __url: "/host/db/host", + }); + expect(nexted).toBe(true); + expect((req as unknown as { userId: string }).userId).toBe("admin1"); + expect(state.auditCalls).toHaveLength(0); + }); + + it("passes through normally when no header is present", async () => { + const { req, nexted } = await runMiddleware(token("regular1")); + expect(nexted).toBe(true); + expect((req as unknown as { userId: string }).userId).toBe("regular1"); + }); +}); diff --git a/src/backend/tests/utils/auth-manager-oidc-logout.test.ts b/src/backend/tests/utils/auth-manager-oidc-logout.test.ts new file mode 100644 index 00000000..007e280e --- /dev/null +++ b/src/backend/tests/utils/auth-manager-oidc-logout.test.ts @@ -0,0 +1,221 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sqlite: null as { + exec: (sql: string) => void; + prepare: (sql: string) => { + all: () => Array>; + run: (...values: unknown[]) => unknown; + }; + } | null, + saveDatabase: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../database/db/index.js", async () => { + const { default: Database } = await import("better-sqlite3"); + const { drizzle } = await import("drizzle-orm/better-sqlite3"); + const sqlite = new Database(":memory:"); + mocks.sqlite = sqlite; + const db = drizzle(sqlite); + return { + db, + getDb: () => db, + saveMemoryDatabaseToFile: mocks.saveDatabase, + }; +}); + +vi.mock("../../utils/user-keys.js", () => ({ + UserKeyManager: { + getInstance: () => ({ + invalidate: vi.fn(), + tryGetUserDEK: vi.fn(() => null), + hasUserDEK: vi.fn(() => true), + }), + }, +})); + +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { getInstance: () => ({}) }, +})); + +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { getInstance: () => ({}) }, +})); + +vi.mock("../../utils/logger.js", () => ({ + authLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +const { AuthManager } = await import("../../utils/auth-manager.js"); +const authManager = AuthManager.getInstance(); + +type SessionInput = { + id: string; + userId: string; + sub: string; + sid: string | null; + providerId: number | null; +}; + +function insertSession({ id, userId, sub, sid, providerId }: SessionInput) { + mocks + .sqlite!.prepare( + `INSERT INTO sessions ( + id, user_id, jwt_token, device_type, device_info, + oidc_sub, oidc_sid, sso_provider_id, + created_at, expires_at, last_active_at + ) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + userId, + `token-${id}`, + sub, + sid, + providerId, + "2026-07-10T00:00:00.000Z", + "2026-07-11T00:00:00.000Z", + "2026-07-10T00:00:00.000Z", + ); +} + +function sessionIds(): string[] { + return mocks + .sqlite!.prepare("SELECT id FROM sessions ORDER BY id") + .all() + .map((row) => String(row.id)); +} + +describe("AuthManager.revokeSessionsByOidc", () => { + beforeAll(() => { + mocks.sqlite!.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + jwt_token TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + oidc_sub TEXT, + oidc_sid TEXT, + sso_provider_id INTEGER, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_active_at TEXT NOT NULL + ) + `); + }); + + beforeEach(() => { + mocks.sqlite!.exec("DELETE FROM sessions"); + mocks.saveDatabase.mockReset().mockResolvedValue(undefined); + }); + + it("revokes only the matching provider session when sid is present", async () => { + insertSession({ + id: "matching", + userId: "user-1", + sub: "subject-1", + sid: "session-1", + providerId: 7, + }); + insertSession({ + id: "other-provider", + userId: "user-1", + sub: "subject-1", + sid: "session-1", + providerId: 8, + }); + insertSession({ + id: "other-session", + userId: "user-1", + sub: "subject-1", + sid: "session-2", + providerId: 7, + }); + + await expect( + authManager.revokeSessionsByOidc({ + ssoProviderId: 7, + sub: "subject-1", + sid: "session-1", + }), + ).resolves.toBe(1); + + expect(sessionIds()).toEqual(["other-provider", "other-session"]); + }); + + it("revokes all provider sessions for a subject when sid is absent", async () => { + insertSession({ + id: "first", + userId: "user-1", + sub: "subject-1", + sid: "session-1", + providerId: 7, + }); + insertSession({ + id: "second", + userId: "user-1", + sub: "subject-1", + sid: "session-2", + providerId: 7, + }); + insertSession({ + id: "other-subject", + userId: "user-2", + sub: "subject-2", + sid: null, + providerId: 7, + }); + + await expect( + authManager.revokeSessionsByOidc({ + ssoProviderId: 7, + sub: "subject-1", + }), + ).resolves.toBe(2); + + expect(sessionIds()).toEqual(["other-subject"]); + }); + + it("does not persist when no session matches", async () => { + await expect( + authManager.revokeSessionsByOidc({ + ssoProviderId: 7, + sid: "missing", + }), + ).resolves.toBe(0); + + expect(mocks.saveDatabase).not.toHaveBeenCalled(); + }); + + it("propagates persistence failures so the provider can retry", async () => { + insertSession({ + id: "matching", + userId: "user-1", + sub: "subject-1", + sid: "session-1", + providerId: 7, + }); + mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full")); + + await expect( + authManager.revokeSessionsByOidc({ + ssoProviderId: 7, + sid: "session-1", + }), + ).rejects.toThrow("disk full"); + }); +}); diff --git a/src/backend/tests/utils/auth-manager-token.test.ts b/src/backend/tests/utils/auth-manager-token.test.ts new file mode 100644 index 00000000..82f10639 --- /dev/null +++ b/src/backend/tests/utils/auth-manager-token.test.ts @@ -0,0 +1,135 @@ +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const jwtSecret = "a".repeat(64); +const encryptionKey = crypto.randomBytes(32); + +const mocks = vi.hoisted(() => ({ + hasUserDEK: vi.fn(() => false), + adoptRecoveredDEK: vi.fn(async () => {}), +})); + +vi.mock("../../database/db/index.js", () => ({ + db: {}, + getDb: () => ({}), + saveMemoryDatabaseToFile: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ get: async () => null }), + createCurrentSessionRepository: () => ({}), + createCurrentUserRepository: () => ({}), + createCurrentApiKeyRepository: () => ({}), + createCurrentTrustedDeviceRepository: () => ({}), + getCurrentSettingValue: () => null, +})); + +vi.mock("../../utils/user-keys.js", () => ({ + UserKeyManager: { + getInstance: () => ({ + hasUserDEK: mocks.hasUserDEK, + tryGetUserDEK: vi.fn(() => null), + invalidate: vi.fn(), + }), + }, +})); + +vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({ + adoptRecoveredDEK: mocks.adoptRecoveredDEK, + migratePasswordUserAtLogin: vi.fn(async () => true), +})); + +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getJWTSecret: async () => jwtSecret, + getEncryptionKey: async () => encryptionKey, + }), + }, +})); + +const { AuthManager } = await import("../../utils/auth-manager.js"); +const authManager = AuthManager.getInstance(); + +function makeLegacyDataKeyWrap( + userId: string, + dek: Buffer, +): Record { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); + cipher.setAAD(Buffer.from(`${userId}:`, "utf8")); + const data = Buffer.concat([cipher.update(dek), cipher.final()]); + return { + version: "v1", + iv: iv.toString("base64url"), + tag: cipher.getAuthTag().toString("base64url"), + data: data.toString("base64url"), + }; +} + +beforeEach(() => { + mocks.hasUserDEK.mockReset().mockReturnValue(false); + mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined); +}); + +describe("AuthManager token handling", () => { + it("issues tokens without a dataKeyWrap", async () => { + const token = await authManager.generateJWTToken("user-1"); + const payload = jwt.decode(token) as Record; + + expect(payload.userId).toBe("user-1"); + expect(payload.dataKeyWrap).toBeUndefined(); + }); + + it("adopts the DEK from a legacy dataKeyWrap token on verify", async () => { + const dek = crypto.randomBytes(32); + const token = jwt.sign( + { userId: "user-1", dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek) }, + jwtSecret, + { expiresIn: "1h" }, + ); + + const payload = await authManager.verifyJWTToken(token); + + expect(payload?.userId).toBe("user-1"); + expect(mocks.adoptRecoveredDEK).toHaveBeenCalledOnce(); + const [userId, adopted] = mocks.adoptRecoveredDEK.mock.calls[0] as [ + string, + Buffer, + ]; + expect(userId).toBe("user-1"); + expect(adopted.equals(dek)).toBe(true); + }); + + it("skips adoption when the user already has a v3 key", async () => { + mocks.hasUserDEK.mockReturnValue(true); + const token = jwt.sign( + { + userId: "user-1", + dataKeyWrap: makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)), + }, + jwtSecret, + { expiresIn: "1h" }, + ); + + await authManager.verifyJWTToken(token); + + expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled(); + }); + + it("tolerates a tampered dataKeyWrap without failing verification", async () => { + const wrap = makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)); + wrap.tag = Buffer.from( + Buffer.from(wrap.tag, "base64url").map((b) => b ^ 0xff), + ).toString("base64url"); + const token = jwt.sign({ userId: "user-1", dataKeyWrap: wrap }, jwtSecret, { + expiresIn: "1h", + }); + + const payload = await authManager.verifyJWTToken(token); + + expect(payload?.userId).toBe("user-1"); + expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/utils/crypto-migration/dek-migration.test.ts b/src/backend/tests/utils/crypto-migration/dek-migration.test.ts new file mode 100644 index 00000000..cf9479e5 --- /dev/null +++ b/src/backend/tests/utils/crypto-migration/dek-migration.test.ts @@ -0,0 +1,330 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const settingsStore = new Map(); +let userRows: Array<{ id: string }> = []; + +vi.mock("../../../database/repositories/factory.js", () => ({ + getCurrentSettingValue: (key: string) => settingsStore.get(key) ?? null, + createCurrentSettingsRepository: () => ({ + upsert: async (key: string, value: string) => { + settingsStore.set(key, value); + }, + set: async (key: string, value: string) => { + settingsStore.set(key, value); + }, + delete: async (key: string) => { + settingsStore.delete(key); + }, + }), + createCurrentUserRepository: () => ({ + listAll: async () => userRows, + }), +})); + +const masterKey = crypto.randomBytes(32); + +vi.mock("../../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getEncryptionKey: async () => masterKey, + }), + }, +})); + +import { UserKeyManager } from "../../../utils/user-keys.js"; +import { + adoptRecoveredDEK, + legacySettingsKeys, + migratePasswordUserAtLogin, + runBootDekMigration, +} from "../../../utils/crypto-migration/dek-migration.js"; + +const manager = UserKeyManager.getInstance(); + +// Fixture helpers replicating the deleted legacy wrap formats exactly. +function legacyEncryptDEK(dek: Buffer, kek: Buffer): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); + const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]); + return JSON.stringify({ + data: encrypted.toString("hex"), + iv: iv.toString("hex"), + tag: cipher.getAuthTag().toString("hex"), + algorithm: "aes-256-gcm", + createdAt: new Date().toISOString(), + }); +} + +function oidcSystemKey(userId: string): Buffer { + return Buffer.from( + crypto.hkdfSync( + "sha256", + masterKey, + Buffer.from(userId, "utf8"), + Buffer.from("termix:oidc-user-kek", "utf8"), + 32, + ), + ); +} + +function webauthnSystemKey(userId: string): Buffer { + return Buffer.from( + crypto.hkdfSync( + "sha256", + masterKey, + Buffer.from(userId, "utf8"), + Buffer.from("termix:webauthn-user-kek", "utf8"), + 32, + ), + ); +} + +function legacyDefaultOidcKey(userId: string): Buffer { + return crypto.pbkdf2Sync( + "termix-oidc-system-secret-default", + Buffer.from(userId, "utf8"), + 100000, + 32, + "sha256", + ); +} + +function passwordKek(password: string, saltHex: string): Buffer { + return crypto.pbkdf2Sync( + password, + Buffer.from(saltHex, "hex"), + 100000, + 32, + "sha256", + ); +} + +function seedPasswordUser(userId: string, password: string): Buffer { + const dek = crypto.randomBytes(32); + const saltHex = crypto.randomBytes(32).toString("hex"); + const keys = legacySettingsKeys(userId); + settingsStore.set( + keys.kekSalt, + JSON.stringify({ + salt: saltHex, + iterations: 100000, + algorithm: "pbkdf2-sha256", + createdAt: new Date().toISOString(), + }), + ); + settingsStore.set( + keys.passwordWrap, + legacyEncryptDEK(dek, passwordKek(password, saltHex)), + ); + return dek; +} + +beforeEach(async () => { + settingsStore.clear(); + userRows = []; + await manager.initialize(masterKey); + manager.clearCache(); +}); + +describe("runBootDekMigration", () => { + it("migrates a pure-OIDC user whose primary slot is system-wrapped", async () => { + const dek = crypto.randomBytes(32); + const keys = legacySettingsKeys("oidc-user"); + settingsStore.set( + keys.passwordWrap, + legacyEncryptDEK(dek, oidcSystemKey("oidc-user")), + ); + userRows = [{ id: "oidc-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("oidc-user").equals(dek)).toBe(true); + }); + + it("migrates via the dedicated oidc wrap slot for dual-auth users", async () => { + const dek = seedPasswordUser("dual-user", "hunter2"); + const keys = legacySettingsKeys("dual-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, oidcSystemKey("dual-user")), + ); + userRows = [{ id: "dual-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("dual-user").equals(dek)).toBe(true); + }); + + it("migrates webauthn-wrapped users", async () => { + const dek = seedPasswordUser("wa-user", "hunter2"); + const keys = legacySettingsKeys("wa-user"); + settingsStore.set( + keys.webauthnWrap, + legacyEncryptDEK(dek, webauthnSystemKey("wa-user")), + ); + userRows = [{ id: "wa-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("wa-user").equals(dek)).toBe(true); + }); + + it("migrates wraps made with the legacy hardcoded default secret", async () => { + const dek = crypto.randomBytes(32); + const keys = legacySettingsKeys("legacy-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, legacyDefaultOidcKey("legacy-user")), + ); + userRows = [{ id: "legacy-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("legacy-user").equals(dek)).toBe(true); + }); + + it("leaves password-only users pending without touching their rows", async () => { + seedPasswordUser("pw-user", "hunter2"); + userRows = [{ id: "pw-user" }]; + const keys = legacySettingsKeys("pw-user"); + const before = { + salt: settingsStore.get(keys.kekSalt), + wrap: settingsStore.get(keys.passwordWrap), + }; + + const summary = await runBootDekMigration({ cleanupLegacy: true }); + + expect(summary.pendingPasswordLogin).toBe(1); + expect(manager.hasUserDEK("pw-user")).toBe(false); + expect(settingsStore.get(keys.kekSalt)).toBe(before.salt); + expect(settingsStore.get(keys.passwordWrap)).toBe(before.wrap); + }); + + it("creates a fresh DEK for users with no key material at all", async () => { + userRows = [{ id: "new-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.created).toBe(1); + expect(manager.hasUserDEK("new-user")).toBe(true); + }); + + it("is idempotent across repeated runs", async () => { + const dek = crypto.randomBytes(32); + const keys = legacySettingsKeys("oidc-user"); + settingsStore.set( + keys.passwordWrap, + legacyEncryptDEK(dek, oidcSystemKey("oidc-user")), + ); + userRows = [{ id: "oidc-user" }]; + + await runBootDekMigration(); + const second = await runBootDekMigration(); + + expect(second.alreadyMigrated).toBe(1); + expect(second.migrated).toBe(0); + manager.clearCache(); + expect(manager.getUserDEK("oidc-user").equals(dek)).toBe(true); + }); + + it("cleans leftover legacy rows when v3 already exists (crash resume)", async () => { + const dek = crypto.randomBytes(32); + await manager.persistDEK("resume-user", dek); + const keys = legacySettingsKeys("resume-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, oidcSystemKey("resume-user")), + ); + userRows = [{ id: "resume-user" }]; + + const summary = await runBootDekMigration({ cleanupLegacy: true }); + + expect(summary.alreadyMigrated).toBe(1); + expect(settingsStore.has(keys.oidcWrap)).toBe(false); + expect(manager.getUserDEK("resume-user").equals(dek)).toBe(true); + }); + + it("removes legacy wraps after migration when cleanup is enabled", async () => { + const dek = seedPasswordUser("dual-user", "hunter2"); + const keys = legacySettingsKeys("dual-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, oidcSystemKey("dual-user")), + ); + userRows = [{ id: "dual-user" }]; + + await runBootDekMigration({ cleanupLegacy: true }); + + expect(settingsStore.has(keys.oidcWrap)).toBe(false); + expect(settingsStore.has(keys.passwordWrap)).toBe(false); + expect(settingsStore.has(keys.kekSalt)).toBe(false); + expect(manager.getUserDEK("dual-user").equals(dek)).toBe(true); + }); +}); + +describe("migratePasswordUserAtLogin", () => { + it("migrates with the correct password and cleans legacy rows", async () => { + const dek = seedPasswordUser("pw-user", "hunter2"); + const keys = legacySettingsKeys("pw-user"); + + const migrated = await migratePasswordUserAtLogin("pw-user", "hunter2"); + + expect(migrated).toBe(true); + expect(manager.getUserDEK("pw-user").equals(dek)).toBe(true); + expect(settingsStore.has(keys.passwordWrap)).toBe(false); + expect(settingsStore.has(keys.kekSalt)).toBe(false); + }); + + it("fails with a wrong password and leaves rows intact", async () => { + seedPasswordUser("pw-user", "hunter2"); + const keys = legacySettingsKeys("pw-user"); + + const migrated = await migratePasswordUserAtLogin("pw-user", "wrong"); + + expect(migrated).toBe(false); + expect(manager.hasUserDEK("pw-user")).toBe(false); + expect(settingsStore.has(keys.passwordWrap)).toBe(true); + expect(settingsStore.has(keys.kekSalt)).toBe(true); + }); + + it("returns true and cleans up when the user is already migrated", async () => { + const dek = seedPasswordUser("pw-user", "hunter2"); + await manager.persistDEK("pw-user", dek); + + const migrated = await migratePasswordUserAtLogin("pw-user", "ignored"); + + expect(migrated).toBe(true); + expect(settingsStore.has(legacySettingsKeys("pw-user").passwordWrap)).toBe( + false, + ); + }); +}); + +describe("adoptRecoveredDEK", () => { + it("persists a session-recovered DEK and cleans legacy rows", async () => { + seedPasswordUser("pw-user", "hunter2"); + const dek = crypto.randomBytes(32); + + await adoptRecoveredDEK("pw-user", dek); + + expect(manager.getUserDEK("pw-user").equals(dek)).toBe(true); + expect(settingsStore.has(legacySettingsKeys("pw-user").passwordWrap)).toBe( + false, + ); + }); + + it("does not overwrite an existing v3 wrap", async () => { + const existing = crypto.randomBytes(32); + await manager.persistDEK("pw-user", existing); + + await adoptRecoveredDEK("pw-user", crypto.randomBytes(32)); + + manager.clearCache(); + expect(manager.getUserDEK("pw-user").equals(existing)).toBe(true); + }); +}); diff --git a/src/backend/tests/utils/crypto-migration/shared-host-secrets-migration.test.ts b/src/backend/tests/utils/crypto-migration/shared-host-secrets-migration.test.ts new file mode 100644 index 00000000..fd2a4a59 --- /dev/null +++ b/src/backend/tests/utils/crypto-migration/shared-host-secrets-migration.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + settings: new Map(), + grants: [] as Array>, + roleMembers: new Map(), + executedSql: [] as string[], + snapshots: [] as Array<[number, number, string, string]>, + usersWithKeys: new Set(), + saves: [] as string[], +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + }), + getCurrentRepositorySqlite: () => ({ + prepare: (sql: string) => ({ + all: (..._params: unknown[]) => { + if (sql.includes("FROM host_access")) return state.grants; + if (sql.includes("FROM user_roles")) { + const roleId = _params[0] as number; + return (state.roleMembers.get(roleId) ?? []).map((id) => ({ + user_id: id, + })); + } + return []; + }, + }), + exec: (sql: string) => { + state.executedSql.push(sql); + }, + }), +})); + +vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ + snapshotForUser: async ( + hostAccessId: number, + hostId: number, + targetUserId: string, + ownerId: string, + ) => { + if (targetUserId === "broken") throw new Error("boom"); + state.snapshots.push([hostAccessId, hostId, targetUserId, ownerId]); + }, + }), + }, +})); + +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + canUserAccessData: (userId: string) => state.usersWithKeys.has(userId), + }, +})); + +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + forceSave: async (reason: string) => { + state.saves.push(reason); + }, + }, +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { runSharedHostSecretsMigration } from "../../../utils/crypto-migration/shared-host-secrets-migration.js"; + +beforeEach(() => { + state.settings.clear(); + state.grants = []; + state.roleMembers.clear(); + state.executedSql = []; + state.snapshots = []; + state.usersWithKeys = new Set(["owner", "alice", "bob"]); + state.saves = []; +}); + +describe("runSharedHostSecretsMigration", () => { + it("re-snapshots direct and role grants, drops the legacy table and sets the flag", async () => { + state.grants = [ + { + hostAccessId: 1, + hostId: 42, + userId: "alice", + roleId: null, + ownerId: "owner", + }, + { + hostAccessId: 2, + hostId: 42, + userId: null, + roleId: 9, + ownerId: "owner", + }, + ]; + state.roleMembers.set(9, ["bob", "owner"]); + + const result = await runSharedHostSecretsMigration(); + + expect(result).toEqual({ snapshotted: 2, skipped: 0 }); + expect(state.snapshots).toEqual([ + [1, 42, "alice", "owner"], + [2, 42, "bob", "owner"], + ]); + expect( + state.executedSql.some((sql) => + sql.includes("DROP TABLE IF EXISTS shared_credentials"), + ), + ).toBe(true); + expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done"); + expect(state.saves).toContain("shared_host_secrets_migration"); + }); + + it("skips grants with missing DEKs and failed snapshots without crashing", async () => { + state.usersWithKeys = new Set(["owner", "alice", "broken"]); + state.grants = [ + { + hostAccessId: 1, + hostId: 42, + userId: "alice", + roleId: null, + ownerId: "owner", + }, + { + hostAccessId: 2, + hostId: 42, + userId: "no-key", + roleId: null, + ownerId: "owner", + }, + { + hostAccessId: 3, + hostId: 42, + userId: "broken", + roleId: null, + ownerId: "owner", + }, + ]; + + const result = await runSharedHostSecretsMigration(); + + expect(result).toEqual({ snapshotted: 1, skipped: 2 }); + expect(state.settings.get("shared_host_secrets_migrated_v1")).toBe("done"); + }); + + it("does nothing when the migration flag is already set", async () => { + state.settings.set("shared_host_secrets_migrated_v1", "done"); + state.grants = [ + { + hostAccessId: 1, + hostId: 42, + userId: "alice", + roleId: null, + ownerId: "owner", + }, + ]; + + const result = await runSharedHostSecretsMigration(); + + expect(result).toBeNull(); + expect(state.snapshots).toEqual([]); + expect(state.executedSql).toEqual([]); + }); +}); diff --git a/src/backend/utils/database-file-encryption.test.ts b/src/backend/tests/utils/database-file-encryption.test.ts similarity index 95% rename from src/backend/utils/database-file-encryption.test.ts rename to src/backend/tests/utils/database-file-encryption.test.ts index db8993b2..0c52b5a9 100644 Binary files a/src/backend/utils/database-file-encryption.test.ts and b/src/backend/tests/utils/database-file-encryption.test.ts differ diff --git a/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts b/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts new file mode 100644 index 00000000..dd570663 --- /dev/null +++ b/src/backend/tests/utils/database-layer-preupgrade-backup.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER, + DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX, + DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV, + ensureDatabaseLayerPreupgradeBackup, +} from "../../utils/database-layer-preupgrade-backup.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-prebackup-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ensureDatabaseLayerPreupgradeBackup", () => { + it("copies existing database files and writes a marker", () => { + const dataDir = makeTempDir(); + const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted"); + const metadataPath = `${encryptedDbPath}.meta`; + const envPath = path.join(dataDir, ".env"); + fs.writeFileSync(encryptedDbPath, "encrypted-db"); + fs.writeFileSync(metadataPath, '{"version":"v2"}'); + fs.writeFileSync(envPath, "DATABASE_KEY=test-key"); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + version: "2.5.0-test", + now: new Date("2026-06-27T12:00:00.000Z"), + env: {} as NodeJS.ProcessEnv, + }); + + expect(result.status).toBe("created"); + expect(result.backupDir).toContain(DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX); + expect( + fs.existsSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + ), + ).toBe(true); + expect( + fs.readFileSync( + path.join(result.backupDir!, "db.sqlite.encrypted"), + "utf8", + ), + ).toBe("encrypted-db"); + expect( + fs.readFileSync( + path.join(result.backupDir!, "db.sqlite.encrypted.meta"), + "utf8", + ), + ).toBe('{"version":"v2"}'); + expect(fs.readFileSync(path.join(result.backupDir!, ".env"), "utf8")).toBe( + "DATABASE_KEY=test-key", + ); + + const manifest = JSON.parse( + fs.readFileSync(path.join(result.backupDir!, "manifest.json"), "utf8"), + ) as { sourceVersion: string }; + expect(manifest.sourceVersion).toBe("2.5.0-test"); + }); + + it("skips when the marker already exists", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); + fs.writeFileSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + "{}", + ); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toMatchObject({ + status: "skipped", + reason: "marker_exists", + }); + expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false); + }); + + it("skips new installs without a database file", () => { + const dataDir = makeTempDir(); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toMatchObject({ + status: "skipped", + reason: "no_database_file", + }); + expect( + fs.existsSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + ), + ).toBe(false); + }); + + it("honors the explicit skip environment variable", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: { + [DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]: "1", + } as NodeJS.ProcessEnv, + }); + + expect(result).toMatchObject({ + status: "skipped", + reason: "skip_env", + }); + expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false); + }); + + it("fails closed when the backup cannot be written", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); + fs.writeFileSync(path.join(dataDir, "backups"), "not-a-directory"); + + expect(() => + ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }), + ).toThrow("Failed to create database layer pre-upgrade backup"); + }); +}); diff --git a/src/backend/tests/utils/database-save-trigger.test.ts b/src/backend/tests/utils/database-save-trigger.test.ts new file mode 100644 index 00000000..82b38e13 --- /dev/null +++ b/src/backend/tests/utils/database-save-trigger.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; + +describe("DatabaseSaveTrigger", () => { + afterEach(() => { + vi.useRealTimers(); + DatabaseSaveTrigger.cleanup(); + }); + + it("force saves through the initialized save function", async () => { + const save = vi.fn().mockResolvedValue(undefined); + DatabaseSaveTrigger.initialize(save); + + await DatabaseSaveTrigger.forceSave("test_force_save"); + + expect(save).toHaveBeenCalledTimes(1); + expect(DatabaseSaveTrigger.getStatus()).toMatchObject({ + initialized: true, + pendingSave: false, + hasPendingTimeout: false, + }); + }); + + it("debounces dirty saves and marks the database clean after saving", async () => { + vi.useFakeTimers(); + const save = vi.fn().mockResolvedValue(undefined); + DatabaseSaveTrigger.initialize(save); + + await DatabaseSaveTrigger.triggerSave("first"); + await DatabaseSaveTrigger.triggerSave("second"); + + expect(DatabaseSaveTrigger.isDirty).toBe(true); + expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true); + + await vi.advanceTimersByTimeAsync(2000); + + expect(save).toHaveBeenCalledTimes(1); + expect(DatabaseSaveTrigger.isDirty).toBe(false); + expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false); + }); +}); diff --git a/src/backend/utils/field-crypto.test.ts b/src/backend/tests/utils/field-crypto.test.ts similarity index 98% rename from src/backend/utils/field-crypto.test.ts rename to src/backend/tests/utils/field-crypto.test.ts index f9c5cedd..52e3803d 100644 --- a/src/backend/utils/field-crypto.test.ts +++ b/src/backend/tests/utils/field-crypto.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import crypto from "crypto"; -import { FieldCrypto } from "./field-crypto.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; const masterKey = crypto.randomBytes(32); diff --git a/src/backend/tests/utils/legacy-sqlite-database-copy-store.test.ts b/src/backend/tests/utils/legacy-sqlite-database-copy-store.test.ts new file mode 100644 index 00000000..a5d2ed1b --- /dev/null +++ b/src/backend/tests/utils/legacy-sqlite-database-copy-store.test.ts @@ -0,0 +1,71 @@ +import Database from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { LegacySqliteDatabaseCopyStore } from "../../utils/legacy-sqlite-database-copy-store.js"; + +describe("LegacySqliteDatabaseCopyStore", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + function createLegacyDatabase(): string { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-legacy-copy-")); + const dbPath = path.join(tempDir, "db.sqlite"); + const db = new Database(dbPath); + + try { + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL + ); + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + INSERT INTO users (id, username) VALUES ('user-1', 'alice'); + INSERT INTO ssh_data (user_id, name) VALUES ('user-1', 'host-1'); + `); + } finally { + db.close(); + } + + return dbPath; + } + + it("copies legacy SQLite schema and rows into a serialized memory buffer", () => { + const dbPath = createLegacyDatabase(); + + const result = + new LegacySqliteDatabaseCopyStore().copyDatabaseToMemoryBuffer(dbPath); + + expect(result.migratedTables).toBe(2); + expect(result.migratedRows).toBe(2); + expect(result.buffer.length).toBeGreaterThan(0); + + const copied = new Database(result.buffer); + try { + expect( + copied.prepare("SELECT username FROM users WHERE id = ?").get("user-1"), + ).toEqual({ username: "alice" }); + expect( + copied + .prepare("SELECT name FROM ssh_data WHERE user_id = ?") + .get("user-1"), + ).toEqual({ name: "host-1" }); + expect(copied.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + } finally { + copied.close(); + } + }); +}); diff --git a/src/backend/utils/login-rate-limiter.test.ts b/src/backend/tests/utils/login-rate-limiter.test.ts similarity index 98% rename from src/backend/utils/login-rate-limiter.test.ts rename to src/backend/tests/utils/login-rate-limiter.test.ts index 9445b663..faeb9ab7 100644 --- a/src/backend/utils/login-rate-limiter.test.ts +++ b/src/backend/tests/utils/login-rate-limiter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { loginRateLimiter } from "./login-rate-limiter.js"; +import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; // The limiter is a shared singleton, so each test uses unique ip/username keys // and resets them to stay isolated. diff --git a/src/backend/tests/utils/oidc-desktop-callback.test.ts b/src/backend/tests/utils/oidc-desktop-callback.test.ts new file mode 100644 index 00000000..0c24d07a --- /dev/null +++ b/src/backend/tests/utils/oidc-desktop-callback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + getDesktopOidcCallbackUrl, + isOidcTokenCallback, +} from "../../utils/oidc-desktop-callback.js"; + +describe("getDesktopOidcCallbackUrl", () => { + it("uses localhost so browsers do not upgrade the loopback callback", () => { + expect(getDesktopOidcCallbackUrl("17850")).toBe( + "http://localhost:17850/oidc-callback", + ); + }); + + it.each(["", "0", "65536", "17850/path", ["17850"]])( + "rejects invalid callback port %j", + (port) => { + expect(getDesktopOidcCallbackUrl(port)).toBeNull(); + }, + ); +}); + +describe("isOidcTokenCallback", () => { + it.each([ + "http://localhost:17850/oidc-callback", + "http://127.0.0.1:17850/oidc-callback", + "termix-mobile://oidc-callback", + ])("recognizes app callback %s", (url) => { + expect(isOidcTokenCallback(url)).toBe(true); + }); + + it.each([ + "https://localhost:17850/oidc-callback", + "http://example.com:17850/oidc-callback", + "http://localhost:17850/other", + ])("rejects non-app callback %s", (url) => { + expect(isOidcTokenCallback(url)).toBe(false); + }); +}); diff --git a/src/backend/tests/utils/permission-catalog.test.ts b/src/backend/tests/utils/permission-catalog.test.ts new file mode 100644 index 00000000..eff44cf7 --- /dev/null +++ b/src/backend/tests/utils/permission-catalog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + PERMISSION_CATALOG, + isValidPermission, +} from "../../utils/permission-catalog.js"; + +describe("permission catalog", () => { + it("accepts every cataloged permission and group wildcard", () => { + for (const entry of PERMISSION_CATALOG) { + expect(isValidPermission(`${entry.group}.*`)).toBe(true); + for (const permission of entry.permissions) { + expect(isValidPermission(permission)).toBe(true); + } + } + }); + + it("accepts the global wildcard", () => { + expect(isValidPermission("*")).toBe(true); + }); + + it("rejects unknown permissions and malformed wildcards", () => { + expect(isValidPermission("hosts.hack")).toBe(false); + expect(isValidPermission("unknown.*")).toBe(false); + expect(isValidPermission("")).toBe(false); + expect(isValidPermission("hosts")).toBe(false); + }); +}); diff --git a/src/backend/tests/utils/permission-manager.test.ts b/src/backend/tests/utils/permission-manager.test.ts new file mode 100644 index 00000000..ae705d03 --- /dev/null +++ b/src/backend/tests/utils/permission-manager.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// permission-manager imports the side-effectful DB barrel and the logger at the +// top level. Stub both so importing the module does not spin up the real +// database / encryption stack. We then drive hasPermission via a spied +// getUserPermissions so we test the wildcard-matching logic in isolation. +vi.mock("../../database/db/index.js", () => ({ db: {} })); +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +const accessState = vi.hoisted(() => ({ + ownerId: "owner" as string, + grant: null as { + id: number; + permissionLevel: string; + expiresAt: string | null; + } | null, + touched: [] as number[], + adminIds: new Set(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + isHostOwnedByUser: async (_hostId: number, userId: string) => + userId === accessState.ownerId, + findHostOwnerId: async () => accessState.ownerId, + }), + createCurrentRbacAccessRepository: () => ({ + findActiveHostAccess: async () => accessState.grant, + touchHostAccess: async (id: number) => { + accessState.touched.push(id); + }, + deleteExpiredHostAccess: async () => 0, + }), + createCurrentRoleRepository: () => ({ + listUserRoleIds: async () => [], + listUserRolePermissions: async () => [], + userHasAnyRoleName: async () => false, + }), + createCurrentUserRepository: () => ({ + findById: async (userId: string) => + accessState.adminIds.has(userId) ? { id: userId, isAdmin: true } : null, + }), +})); + +const { PermissionManager } = await import("../../utils/permission-manager.js"); + +type PermissionManagerInstance = ReturnType< + typeof PermissionManager.getInstance +>; + +describe("PermissionManager.hasPermission wildcard matching", () => { + let manager: PermissionManagerInstance; + + function withPermissions(permissions: string[]) { + vi.spyOn(manager, "getUserPermissions").mockResolvedValue(permissions); + } + + beforeEach(() => { + manager = PermissionManager.getInstance(); + vi.restoreAllMocks(); + }); + + it("grants everything for the global wildcard '*'", async () => { + withPermissions(["*"]); + expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); + expect(await manager.hasPermission("u1", "anything.at.all")).toBe(true); + }); + + it("grants an exact permission match", async () => { + withPermissions(["hosts.read", "hosts.write"]); + expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); + }); + + it("grants via a prefix wildcard", async () => { + withPermissions(["hosts.*"]); + expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); + expect(await manager.hasPermission("u1", "hosts.write")).toBe(true); + }); + + it("grants via a deep prefix wildcard", async () => { + withPermissions(["admin.users.*"]); + expect(await manager.hasPermission("u1", "admin.users.delete")).toBe(true); + }); + + it("denies when no exact or wildcard permission matches", async () => { + withPermissions(["hosts.read"]); + expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); + expect(await manager.hasPermission("u1", "credentials.read")).toBe(false); + }); + + it("denies when the user has no permissions", async () => { + withPermissions([]); + expect(await manager.hasPermission("u1", "hosts.read")).toBe(false); + }); + + it("does not let a narrower wildcard grant a sibling branch", async () => { + withPermissions(["hosts.read.*"]); + expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); + }); +}); + +describe("PermissionManager.canAccessHost level hierarchy", () => { + const manager = PermissionManager.getInstance(); + const actions = ["connect", "view", "edit", "manage"] as const; + const levels = ["connect", "view", "edit", "manage"] as const; + const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const; + + beforeEach(() => { + vi.restoreAllMocks(); + accessState.ownerId = "owner"; + accessState.grant = null; + accessState.touched = []; + accessState.adminIds = new Set(); + }); + + it("grants the owner every action including delete", async () => { + for (const action of [...actions, "delete"] as const) { + const info = await manager.canAccessHost("owner", 42, action); + expect(info).toMatchObject({ hasAccess: true, isOwner: true }); + } + }); + + it("denies everything without a grant", async () => { + const info = await manager.canAccessHost("stranger", 42, "connect"); + expect(info).toMatchObject({ hasAccess: false, isShared: false }); + }); + + it("enforces the connect < view < edit < manage hierarchy", async () => { + for (const level of levels) { + accessState.grant = { id: 5, permissionLevel: level, expiresAt: null }; + for (const action of actions) { + const info = await manager.canAccessHost("recipient", 42, action); + expect(info.hasAccess).toBe(rank[level] >= rank[action]); + expect(info.permissionLevel).toBe(level); + expect(info.isShared).toBe(true); + } + } + }); + + it("never grants delete to a shared recipient", async () => { + accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null }; + const info = await manager.canAccessHost("recipient", 42, "delete"); + expect(info.hasAccess).toBe(false); + }); + + it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => { + accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null }; + const connect = await manager.canAccessHost("recipient", 42, "connect"); + expect(connect.hasAccess).toBe(true); + expect(connect.permissionLevel).toBe("connect"); + + const view = await manager.canAccessHost("recipient", 42, "view"); + expect(view.hasAccess).toBe(false); + }); + + it("only touches the grant timestamp on connect", async () => { + accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null }; + await manager.canAccessHost("recipient", 42, "manage"); + expect(accessState.touched).toEqual([]); + await manager.canAccessHost("recipient", 42, "connect"); + expect(accessState.touched).toEqual([5]); + }); + + it("grants admins owner-equivalent access to any host via bypass", async () => { + accessState.adminIds = new Set(["adminUser"]); + for (const action of actions) { + const info = await manager.canAccessHost("adminUser", 42, action); + expect(info).toMatchObject({ + hasAccess: true, + isOwner: false, + isAdminBypass: true, + permissionLevel: "manage", + }); + } + }); + + it("upgrades an under-privileged admin's share access via bypass", async () => { + accessState.adminIds = new Set(["adminUser"]); + accessState.grant = { id: 7, permissionLevel: "connect", expiresAt: null }; + const info = await manager.canAccessHost("adminUser", 42, "manage"); + expect(info).toMatchObject({ hasAccess: true, isAdminBypass: true }); + }); + + it("does not grant a non-admin stranger admin bypass", async () => { + const info = await manager.canAccessHost("stranger", 42, "manage"); + expect(info.hasAccess).toBe(false); + expect(info.isAdminBypass).toBeUndefined(); + }); +}); diff --git a/src/backend/utils/request-origin.test.ts b/src/backend/tests/utils/request-origin.test.ts similarity index 70% rename from src/backend/utils/request-origin.test.ts rename to src/backend/tests/utils/request-origin.test.ts index d569f6e9..39d712c7 100644 --- a/src/backend/utils/request-origin.test.ts +++ b/src/backend/tests/utils/request-origin.test.ts @@ -3,8 +3,9 @@ import { getRequestBasePath, getRequestBaseUrl, getRequestBaseUrlWithForceHTTPS, + getRequestOrigin, normalizeBasePath, -} from "./request-origin.js"; +} from "../../utils/request-origin.js"; function request(headers: Record) { return { @@ -106,3 +107,52 @@ describe("getRequestBasePath", () => { ).toBe("https://example.com/termix"); }); }); + +describe("getRequestOrigin", () => { + it("ignores non-numeric forwarded ports", () => { + expect( + getRequestOrigin( + request({ + "x-forwarded-proto": "https", + "x-forwarded-host": "termix.test.de", + "x-forwarded-port": "{server_port}", + }), + ), + ).toBe("https://termix.test.de"); + }); + + it("drops invalid ports embedded in forwarded hosts", () => { + expect( + getRequestOrigin( + request({ + "x-forwarded-proto": "https", + "x-forwarded-host": "termix.test.de:{server_port}", + }), + ), + ).toBe("https://termix.test.de"); + }); + + it("keeps valid non-default forwarded ports", () => { + expect( + getRequestOrigin( + request({ + "x-forwarded-proto": "https", + "x-forwarded-host": "termix.test.de", + "x-forwarded-port": "8443", + }), + ), + ).toBe("https://termix.test.de:8443"); + }); + + it("omits default forwarded ports", () => { + expect( + getRequestOrigin( + request({ + "x-forwarded-proto": "https", + "x-forwarded-host": "termix.test.de", + "x-forwarded-port": "443", + }), + ), + ).toBe("https://termix.test.de"); + }); +}); diff --git a/src/backend/tests/utils/shared-host-secrets-manager.test.ts b/src/backend/tests/utils/shared-host-secrets-manager.test.ts new file mode 100644 index 00000000..9c41e806 --- /dev/null +++ b/src/backend/tests/utils/shared-host-secrets-manager.test.ts @@ -0,0 +1,347 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ownerDEK = crypto.randomBytes(32); +const targetDEK = crypto.randomBytes(32); + +type SecretRow = Record & { + id: number; + hostAccessId: number; + targetUserId: string; + protocol: string; +}; + +const state = vi.hoisted(() => ({ + hosts: new Map>(), + credentials: new Map>(), + secretRows: [] as Array>, + // hostAccessId -> hostId, used by findForHostUserProtocol + accessToHost: new Map(), + grants: [] as Array>, + roleMembers: new Map(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null, + findHostOwnerId: async (hostId: number) => + (state.hosts.get(hostId)?.userId as string) ?? null, + findCredentialByIdForUser: async (credentialId: number) => + state.credentials.get(credentialId) ?? null, + }), + createCurrentSharedHostSecretsRepository: () => ({ + upsert: async (row: Record) => { + const existing = state.secretRows.find( + (r) => + r.hostAccessId === row.hostAccessId && + r.targetUserId === row.targetUserId && + r.protocol === row.protocol, + ); + if (existing) Object.assign(existing, row); + else state.secretRows.push({ id: state.secretRows.length + 1, ...row }); + }, + deleteForHostAccessAndTarget: async ( + hostAccessId: number, + targetUserId: string, + keepProtocols: string[], + ) => { + state.secretRows = state.secretRows.filter( + (r) => + !( + r.hostAccessId === hostAccessId && + r.targetUserId === targetUserId && + !keepProtocols.includes(r.protocol as string) + ), + ); + }, + findForHostUserProtocol: async ( + hostId: number, + targetUserId: string, + protocol: string, + ) => + state.secretRows.find( + (r) => + state.accessToHost.get(r.hostAccessId as number) === hostId && + r.targetUserId === targetUserId && + r.protocol === protocol, + ) ?? null, + deleteByHostAccessId: async () => 0, + deleteByTargetUserId: async () => 0, + deleteByOriginalCredentialId: async () => 0, + findHostIdsReferencingCredential: async ( + _ownerId: string, + credentialId: number, + ) => + [...state.hosts.values()] + .filter((h) => h.credentialId === credentialId) + .map((h) => h.id as number), + }), + createCurrentRbacAccessRepository: () => ({ + listActiveHostAccessGrants: async (hostId: number) => + state.grants.filter((g) => g.hostId === hostId), + listRoleHostAccessCredentialSources: async (roleId: number) => + state.grants + .filter((g) => g.roleId === roleId) + .map((g) => ({ + hostAccessId: g.id, + hostId: g.hostId, + hostOwnerId: state.hosts.get(g.hostId as number)?.userId, + })), + }), + createCurrentRoleRepository: () => ({ + listRoleUserIds: async (roleId: number) => + state.roleMembers.get(roleId) ?? [], + listUserRoleIds: async () => [], + }), +})); + +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { + validateUserAccess: (userId: string) => { + if (userId === "owner") return ownerDEK; + if (userId === "target" || userId === "member-1") return targetDEK; + throw new Error(`User ${userId} has no data encryption key`); + }, + getUserDataKey: (userId: string) => + userId === "owner" + ? ownerDEK + : userId === "target" || userId === "member-1" + ? targetDEK + : null, + canUserAccessData: () => true, + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { FieldCrypto } from "../../utils/field-crypto.js"; +import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js"; + +const manager = SharedHostSecretsManager.getInstance(); + +function baseHost(overrides: Record = {}) { + // Records come back from the resolution repository already decrypted. + return { + id: 42, + userId: "owner", + connectionType: "ssh", + name: "prod", + ip: "10.0.0.42", + username: "root", + authType: "password", + password: "hunter2", + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + rdpAuthType: null, + vncAuthType: null, + telnetAuthType: null, + rdpCredentialId: null, + vncCredentialId: null, + telnetCredentialId: null, + rdpUser: null, + rdpPassword: null, + rdpDomain: null, + vncUser: null, + vncPassword: null, + telnetUser: null, + telnetPassword: null, + ...overrides, + }; +} + +beforeEach(() => { + state.hosts.clear(); + state.credentials.clear(); + state.secretRows = []; + state.accessToHost = new Map([[7, 42]]); + state.grants = []; + state.roleMembers.clear(); +}); + +describe("SharedHostSecretsManager", () => { + it("snapshots an inline-password SSH host and the target can decrypt it", async () => { + state.hosts.set(42, baseHost()); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows).toHaveLength(1); + const row = state.secretRows[0]; + expect(row.protocol).toBe("ssh"); + expect(row.sourceType).toBe("inline"); + expect(row.encryptedPassword).not.toBe("hunter2"); + + const secret = await manager.getSecretForUser(42, "target", "ssh"); + expect(secret).toMatchObject({ + username: "root", + authType: "password", + password: "hunter2", + }); + }); + + it("snapshots every enabled protocol from credential and inline sources", async () => { + state.credentials.set(123, { + id: 123, + userId: "owner", + username: "cred-user", + authType: "key", + password: null, + privateKey: "PRIVATE-KEY", + key: null, + keyPassword: "kp", + keyType: "ssh-ed25519", + }); + state.hosts.set( + 42, + baseHost({ + authType: "credential", + credentialId: 123, + password: null, + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + rdpDomain: "CORP", + enableTelnet: true, + telnetUser: "tel-user", + telnetPassword: "tel-pass", + }), + ); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([ + "rdp", + "ssh", + "telnet", + ]); + + const ssh = await manager.getSecretForUser(42, "target", "ssh"); + expect(ssh).toMatchObject({ + username: "cred-user", + authType: "key", + key: "PRIVATE-KEY", + keyPassword: "kp", + keyType: "ssh-ed25519", + }); + + const rdp = await manager.getSecretForUser(42, "target", "rdp"); + expect(rdp).toMatchObject({ + username: "rdp-admin", + password: "rdp-pass", + domain: "CORP", + authType: "direct", + }); + + const telnet = await manager.getSecretForUser(42, "target", "telnet"); + expect(telnet).toMatchObject({ + username: "tel-user", + password: "tel-pass", + }); + }); + + it("produces no snapshot rows for secret-less auth types", async () => { + state.hosts.set(42, baseHost({ authType: "opkssh", password: null })); + + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows).toHaveLength(0); + }); + + it("removes stale protocol rows on re-snapshot", async () => { + state.hosts.set( + 42, + baseHost({ + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + }), + ); + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows).toHaveLength(2); + + // Owner turns RDP off; the RDP snapshot must disappear. + state.hosts.set(42, baseHost()); + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]); + }); + + it("fails fast when a participant has no DEK", async () => { + state.hosts.set(42, baseHost()); + await expect( + manager.snapshotForUser(7, 42, "locked-user", "owner"), + ).rejects.toThrow(/no data encryption key/); + expect(state.secretRows).toHaveLength(0); + }); + + it("cannot be decrypted with the wrong DEK", async () => { + state.hosts.set(42, baseHost()); + await manager.snapshotForUser(7, 42, "target", "owner"); + + const row = state.secretRows[0]; + expect(() => + FieldCrypto.decryptField( + row.encryptedPassword as string, + ownerDEK, + "shared-7-target-ssh", + "password", + ), + ).toThrow(); + }); + + it("resyncHost re-snapshots direct grants and role members", async () => { + state.hosts.set(42, baseHost()); + state.accessToHost = new Map([ + [1, 42], + [2, 42], + ]); + state.grants = [ + { id: 1, hostId: 42, userId: "target", roleId: null }, + { id: 2, hostId: 42, userId: null, roleId: 9 }, + ]; + state.roleMembers.set(9, ["member-1", "owner"]); + + await manager.resyncHost(42); + + // target via grant 1, member-1 via grant 2; owner skipped. + expect( + state.secretRows.map((row) => [row.hostAccessId, row.targetUserId]), + ).toEqual([ + [1, "target"], + [2, "member-1"], + ]); + + // Owner rotates the inline password; resync updates the copies. + state.hosts.set(42, baseHost({ password: "rotated" })); + await manager.resyncHost(42); + + const secret = await manager.getSecretForUser(42, "target", "ssh"); + expect(secret?.password).toBe("rotated"); + }); + + it("snapshotForRoleMember fans out from role grants", async () => { + state.hosts.set(42, baseHost()); + state.accessToHost = new Map([[2, 42]]); + state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }]; + + await manager.snapshotForRoleMember(9, "member-1"); + + expect(state.secretRows).toHaveLength(1); + expect(state.secretRows[0]).toMatchObject({ + hostAccessId: 2, + targetUserId: "member-1", + protocol: "ssh", + }); + }); +}); diff --git a/src/backend/utils/ssh-key-utils.test.ts b/src/backend/tests/utils/ssh-key-utils.test.ts similarity index 99% rename from src/backend/utils/ssh-key-utils.test.ts rename to src/backend/tests/utils/ssh-key-utils.test.ts index 52d7669a..756dc662 100644 --- a/src/backend/utils/ssh-key-utils.test.ts +++ b/src/backend/tests/utils/ssh-key-utils.test.ts @@ -6,7 +6,7 @@ import { preparePrivateKeyForSSH2, getFriendlyKeyTypeName, validateKeyPair, -} from "./ssh-key-utils.js"; +} from "../../utils/ssh-key-utils.js"; // A real OpenSSH ed25519 keypair generated solely for these tests. It grants no // access to anything and exists only so the ssh2 parsing path is exercised for diff --git a/src/backend/utils/user-agent-parser.test.ts b/src/backend/tests/utils/user-agent-parser.test.ts similarity index 98% rename from src/backend/utils/user-agent-parser.test.ts rename to src/backend/tests/utils/user-agent-parser.test.ts index df84ac47..9e002b2c 100644 --- a/src/backend/utils/user-agent-parser.test.ts +++ b/src/backend/tests/utils/user-agent-parser.test.ts @@ -4,7 +4,7 @@ import { detectPlatform, parseUserAgent, generateDeviceFingerprint, -} from "./user-agent-parser.js"; +} from "../../utils/user-agent-parser.js"; function reqWith(headers: Record): Request { return { headers } as unknown as Request; diff --git a/src/backend/tests/utils/user-encryption-migration-store.test.ts b/src/backend/tests/utils/user-encryption-migration-store.test.ts new file mode 100644 index 00000000..0f37c521 --- /dev/null +++ b/src/backend/tests/utils/user-encryption-migration-store.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { RawSqliteUserEncryptionMigrationStore } from "../../utils/user-encryption-migration-store.js"; + +describe("RawSqliteUserEncryptionMigrationStore", () => { + function createDb() { + const all = vi.fn(() => [{ id: 1 }]); + const get = vi.fn(() => ({ id: "user-1" })); + const run = vi.fn(); + const prepare = vi.fn(() => ({ all, get, run })); + + return { db: { prepare }, all, get, run }; + } + + it("owns legacy user encryption migration reads", () => { + const { db, all, get } = createDb(); + const store = new RawSqliteUserEncryptionMigrationStore(db); + + expect(store.listHostRecords("user-1")).toEqual([{ id: 1 }]); + expect(store.listCredentialRecords("user-1")).toEqual([{ id: 1 }]); + expect(store.getUserRecord("user-1")).toEqual({ id: "user-1" }); + + expect(db.prepare).toHaveBeenCalledWith( + "SELECT * FROM ssh_data WHERE user_id = ?", + ); + expect(db.prepare).toHaveBeenCalledWith( + "SELECT * FROM ssh_credentials WHERE user_id = ?", + ); + expect(db.prepare).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?"); + expect(all).toHaveBeenCalledWith("user-1"); + expect(get).toHaveBeenCalledWith("user-1"); + }); + + it("owns legacy sensitive field update statements", () => { + const { db, run } = createDb(); + const store = new RawSqliteUserEncryptionMigrationStore(db); + + store.updateHostSensitiveFields(12, { + password: "p", + key: "k", + key_password: "kp", + key_type: "ed25519", + autostart_password: "ap", + autostart_key: "ak", + autostart_key_password: "akp", + sudo_password: "sp", + }); + store.updateCredentialSensitiveFields(13, { + password: "p", + key: "k", + key_password: "kp", + private_key: "priv", + public_key: "pub", + key_type: "rsa", + }); + store.updateUserSensitiveFields("user-1", { + totp_secret: "totp", + totp_backup_codes: "codes", + client_secret: "client", + oidc_identifier: "oidc", + }); + + expect(db.prepare).toHaveBeenCalledWith( + expect.stringContaining("UPDATE ssh_data"), + ); + expect(db.prepare).toHaveBeenCalledWith( + expect.stringContaining("UPDATE ssh_credentials"), + ); + expect(db.prepare).toHaveBeenCalledWith( + expect.stringContaining("UPDATE users"), + ); + expect(run).toHaveBeenCalledWith( + "p", + "k", + "kp", + "ed25519", + "ap", + "ak", + "akp", + "sp", + 12, + ); + expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13); + expect(run).toHaveBeenCalledWith( + "totp", + "codes", + "client", + "oidc", + "user-1", + ); + }); + + it("owns password-reset dynamic field updates", () => { + const { db, run } = createDb(); + const store = new RawSqliteUserEncryptionMigrationStore(db); + + store.updatePasswordResetFields( + "ssh_credentials", + 99, + ["password", "key"], + { password: "p", key: "k" }, + ); + + expect(db.prepare).toHaveBeenCalledWith( + "UPDATE ssh_credentials SET password = ?, key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + ); + expect(run).toHaveBeenCalledWith("p", "k", 99); + }); +}); diff --git a/src/backend/tests/utils/user-keys.test.ts b/src/backend/tests/utils/user-keys.test.ts new file mode 100644 index 00000000..b23c5ad3 --- /dev/null +++ b/src/backend/tests/utils/user-keys.test.ts @@ -0,0 +1,161 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const settingsStore = new Map(); + +vi.mock("../../database/repositories/factory.js", () => ({ + getCurrentSettingValue: (key: string) => settingsStore.get(key) ?? null, + createCurrentSettingsRepository: () => ({ + upsert: async (key: string, value: string) => { + settingsStore.set(key, value); + }, + delete: async (key: string) => { + settingsStore.delete(key); + }, + }), +})); + +import { + UserKeyManager, + UserKeyUnavailableError, + userDekSettingsKey, +} from "../../utils/user-keys.js"; + +const masterKey = crypto.randomBytes(32); +const manager = UserKeyManager.getInstance(); + +function storedWrap(userId: string): Record { + return JSON.parse(settingsStore.get(userDekSettingsKey(userId))!); +} + +function putWrap(userId: string, wrap: Record): void { + settingsStore.set(userDekSettingsKey(userId), JSON.stringify(wrap)); +} + +beforeEach(async () => { + settingsStore.clear(); + await manager.initialize(masterKey); +}); + +describe("UserKeyManager", () => { + it("creates and round-trips a DEK", async () => { + const dek = await manager.createUserDEK("user-1"); + + expect(dek).toHaveLength(32); + expect(manager.hasUserDEK("user-1")).toBe(true); + + manager.clearCache(); + expect(manager.getUserDEK("user-1").equals(dek)).toBe(true); + }); + + it("refuses to create a second DEK for the same user", async () => { + await manager.createUserDEK("user-1"); + await expect(manager.createUserDEK("user-1")).rejects.toThrow( + /already has a data encryption key/, + ); + }); + + it("throws missing for a user with no wrap and no legacy rows", () => { + expect(() => manager.getUserDEK("ghost")).toThrow(UserKeyUnavailableError); + try { + manager.getUserDEK("ghost"); + } catch (error) { + expect((error as UserKeyUnavailableError).reason).toBe("missing"); + } + expect(manager.tryGetUserDEK("ghost")).toBeNull(); + }); + + it("throws pending_migration when only legacy wrap rows exist", () => { + settingsStore.set("user_encrypted_dek_legacy-user", "{}"); + try { + manager.getUserDEK("legacy-user"); + expect.unreachable(); + } catch (error) { + expect((error as UserKeyUnavailableError).reason).toBe( + "pending_migration", + ); + } + + settingsStore.clear(); + settingsStore.set("user_kek_salt_legacy-user", "{}"); + try { + manager.getUserDEK("legacy-user"); + expect.unreachable(); + } catch (error) { + expect((error as UserKeyUnavailableError).reason).toBe( + "pending_migration", + ); + } + }); + + it("fails to unwrap with a different master key", async () => { + const dek = await manager.createUserDEK("user-1"); + + await manager.initialize(crypto.randomBytes(32)); + expect(() => manager.getUserDEK("user-1")).toThrow(); + + await manager.initialize(masterKey); + expect(manager.getUserDEK("user-1").equals(dek)).toBe(true); + }); + + it("rejects tampered iv, ciphertext and tag", async () => { + await manager.createUserDEK("user-1"); + + for (const field of ["iv", "ct", "tag"] as const) { + const wrap = storedWrap("user-1"); + const bytes = Buffer.from(wrap[field] as string, "base64"); + bytes[0] ^= 0xff; + putWrap("user-1", { ...wrap, [field]: bytes.toString("base64") }); + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(); + } + }); + + it("rejects a wrap moved to another user (AAD/info binding)", async () => { + await manager.createUserDEK("user-a"); + + putWrap("user-b", storedWrap("user-a")); + manager.clearCache(); + expect(() => manager.getUserDEK("user-b")).toThrow(); + }); + + it("rejects unknown wrap versions and algorithms", async () => { + await manager.createUserDEK("user-1"); + + const wrap = storedWrap("user-1"); + putWrap("user-1", { ...wrap, v: 99 }); + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(/Unsupported key wrap/); + + putWrap("user-1", { ...wrap, alg: "aes-128-gcm" }); + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(/Unsupported key wrap/); + }); + + it("persistDEK overwrites and rotateUserDEK replaces the key", async () => { + const original = await manager.createUserDEK("user-1"); + const rotated = await manager.rotateUserDEK("user-1"); + + expect(rotated.equals(original)).toBe(false); + manager.clearCache(); + expect(manager.getUserDEK("user-1").equals(rotated)).toBe(true); + }); + + it("deleteUserDEK removes the wrap and cached key", async () => { + await manager.createUserDEK("user-1"); + await manager.deleteUserDEK("user-1"); + + expect(manager.hasUserDEK("user-1")).toBe(false); + expect(manager.tryGetUserDEK("user-1")).toBeNull(); + }); + + it("serves cached DEKs without re-reading settings", async () => { + const dek = await manager.createUserDEK("user-1"); + + settingsStore.delete(userDekSettingsKey("user-1")); + expect(manager.getUserDEK("user-1").equals(dek)).toBe(true); + + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(UserKeyUnavailableError); + }); +}); diff --git a/src/backend/utils/wake-on-lan.test.ts b/src/backend/tests/utils/wake-on-lan.test.ts similarity index 97% rename from src/backend/utils/wake-on-lan.test.ts rename to src/backend/tests/utils/wake-on-lan.test.ts index eb681d06..a53adf21 100644 --- a/src/backend/utils/wake-on-lan.test.ts +++ b/src/backend/tests/utils/wake-on-lan.test.ts @@ -12,7 +12,11 @@ vi.mock("dgram", () => { }); import dgram from "dgram"; -import { isValidMac, buildMagicPacket, sendWakeOnLan } from "./wake-on-lan.js"; +import { + isValidMac, + buildMagicPacket, + sendWakeOnLan, +} from "../../utils/wake-on-lan.js"; describe("isValidMac", () => { it("accepts colon-separated MAC addresses", () => { diff --git a/src/backend/utils/audit-logger.test.ts b/src/backend/utils/audit-logger.test.ts deleted file mode 100644 index e88862ce..00000000 --- a/src/backend/utils/audit-logger.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("../database/db/index.js", () => ({ - db: { - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }), - $client: { - prepare: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ count: 0 }), - run: vi.fn(), - }), - }, - }, -})); - -import { logAudit, getRequestMeta } from "./audit-logger.js"; -import { db } from "../database/db/index.js"; - -const mockDb = db as { - insert: ReturnType; - $client: { prepare: ReturnType }; -}; - -describe("logAudit", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockDb.insert.mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }); - mockDb.$client.prepare.mockReturnValue({ - get: vi.fn().mockReturnValue({ count: 0 }), - run: vi.fn(), - }); - }); - - it("inserts an audit log entry with all required fields", async () => { - const params = { - userId: "user-1", - username: "alice", - action: "create_host", - resourceType: "host", - resourceId: "42", - resourceName: "my-server", - ipAddress: "1.2.3.4", - userAgent: "Mozilla/5.0", - success: true, - }; - - await logAudit(params); - - expect(mockDb.insert).toHaveBeenCalledOnce(); - const valuesFn = mockDb.insert.mock.results[0].value.values; - expect(valuesFn).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "user-1", - username: "alice", - action: "create_host", - resourceType: "host", - resourceId: "42", - resourceName: "my-server", - success: true, - }), - ); - }); - - it("does not throw when insert fails", async () => { - mockDb.insert.mockReturnValue({ - values: vi.fn().mockRejectedValue(new Error("db error")), - }); - - await expect( - logAudit({ - userId: "u", - username: "u", - action: "x", - resourceType: "y", - success: false, - }), - ).resolves.toBeUndefined(); - }); - - it("triggers pruning when row count exceeds threshold", async () => { - const prepareMock = vi.fn(); - const runMock = vi.fn(); - prepareMock.mockImplementation((sql: string) => { - if (sql.includes("COUNT(*)")) { - return { get: () => ({ count: 10001 }) }; - } - return { run: runMock }; - }); - mockDb.$client.prepare = prepareMock; - - await logAudit({ - userId: "u", - username: "u", - action: "x", - resourceType: "y", - success: true, - }); - - const deleteCalled = prepareMock.mock.calls.some((args: unknown[]) => - String(args[0]).includes("DELETE"), - ); - expect(deleteCalled).toBe(true); - expect(runMock).toHaveBeenCalledWith(1001); - }); -}); - -describe("getRequestMeta", () => { - it("extracts ip from x-forwarded-for header", () => { - const req = { - headers: { - "x-forwarded-for": "10.0.0.1, 10.0.0.2", - "user-agent": "TestAgent/1.0", - }, - ip: "127.0.0.1", - }; - const meta = getRequestMeta(req as never); - expect(meta.ipAddress).toBe("10.0.0.1"); - expect(meta.userAgent).toBe("TestAgent/1.0"); - }); - - it("falls back to req.ip when no forwarded header", () => { - const req = { - headers: { "user-agent": "Bot/2" }, - ip: "192.168.1.1", - }; - const meta = getRequestMeta(req as never); - expect(meta.ipAddress).toBe("192.168.1.1"); - }); -}); diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index 9fe31acb..c48c0380 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -1,9 +1,5 @@ import type { Request } from "express"; -import { db } from "../database/db/index.js"; -import { auditLogs } from "../database/db/schema.js"; - -const PRUNE_MAX = 10000; -const PRUNE_TARGET = 9000; +import { createCurrentAuditLogRepository } from "../database/repositories/factory.js"; export interface AuditLogParams { userId: string; @@ -21,7 +17,7 @@ export interface AuditLogParams { export async function logAudit(params: AuditLogParams): Promise { try { - await db.insert(auditLogs).values({ + await createCurrentAuditLogRepository().create({ userId: params.userId, username: params.username, action: params.action, @@ -34,21 +30,6 @@ export async function logAudit(params: AuditLogParams): Promise { success: params.success, errorMessage: params.errorMessage ?? null, }); - - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM audit_logs") - .get() as { count: number }; - - if (countResult.count >= PRUNE_MAX) { - const deleteCount = countResult.count - PRUNE_TARGET; - db.$client - .prepare( - `DELETE FROM audit_logs WHERE id IN ( - SELECT id FROM audit_logs ORDER BY timestamp ASC LIMIT ? - )`, - ) - .run(deleteCount); - } } catch { // audit logging must never throw and break the caller } diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index c89e8b2b..6d2a29c6 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -1,16 +1,29 @@ import jwt from "jsonwebtoken"; import crypto from "crypto"; -import { UserCrypto } from "./user-crypto.js"; +import { UserKeyManager } from "./user-keys.js"; +import { + adoptRecoveredDEK, + migratePasswordUserAtLogin, +} from "./crypto-migration/dek-migration.js"; import { SystemCrypto } from "./system-crypto.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger, authLogger } from "./logger.js"; +import { logAudit, getRequestMeta } from "./audit-logger.js"; import type { Request, Response, NextFunction } from "express"; import bcrypt from "bcryptjs"; -import { db } from "../database/db/index.js"; -import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js"; -import { eq, and, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; +import { and, eq, inArray } from "drizzle-orm"; import type { DeviceType } from "./user-agent-parser.js"; +import { getDb } from "../database/db/index.js"; +import { sessions } from "../database/db/schema.js"; +import { + createCurrentSettingsRepository, + createCurrentSessionRepository, + createCurrentUserRepository, + createCurrentApiKeyRepository, + createCurrentTrustedDeviceRepository, + getCurrentSettingValue, +} from "../database/repositories/factory.js"; interface AuthenticationResult { success: boolean; @@ -42,8 +55,10 @@ interface WrappedDataKey { interface AuthenticatedRequest extends Request { userId?: string; sessionId?: string; + apiKeyId?: string; pendingTOTP?: boolean; dataKey?: Buffer; + actingAdminUserId?: string; } interface RequestWithHeaders extends Request { @@ -52,18 +67,24 @@ interface RequestWithHeaders extends Request { }; } +const ADMIN_TARGET_USER_HEADER = "x-admin-target-user"; + +// Data-plane routes an admin may hit on behalf of another user. Everything +// else (TOTP, sessions, tunnels, file manager, ...) rejects the header. +const IMPERSONATION_PATH_ALLOWLIST = [ + /^\/host\/db\//, + /^\/credentials(\/|$)/, + /^\/snippets(\/|$)/, +]; + class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; - private userCrypto: UserCrypto; + private userKeys: UserKeyManager; private constructor() { this.systemCrypto = SystemCrypto.getInstance(); - this.userCrypto = UserCrypto.getInstance(); - - this.userCrypto.setSessionExpiredCallback((userId: string) => { - this.invalidateUserTokens(userId); - }); + this.userKeys = UserKeyManager.getInstance(); setInterval( () => { @@ -92,88 +113,65 @@ class AuthManager { await this.systemCrypto.initializeJWTSecret(); } - async registerUser(userId: string, password: string): Promise { - await this.userCrypto.setupUserEncryption(userId, password); + async registerUser(userId: string, _password?: string): Promise { + if (!this.userKeys.hasUserDEK(userId)) { + await this.userKeys.createUserDEK(userId); + } } async registerOIDCUser( userId: string, - sessionDurationMs: number, + _sessionDurationMs?: number, ): Promise { - await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs); + if (!this.userKeys.hasUserDEK(userId)) { + await this.userKeys.createUserDEK(userId); + } + } + + private async ensureUserDEK(userId: string): Promise { + if (this.userKeys.tryGetUserDEK(userId)) { + await this.performLazyEncryptionMigration(userId); + return true; + } + return false; } async authenticateOIDCUser( userId: string, - deviceType?: DeviceType, + _deviceType?: DeviceType, ): Promise { - const sessionDurationMs = - deviceType === "desktop" || deviceType === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - - const authenticated = await this.userCrypto.authenticateOIDCUser( - userId, - sessionDurationMs, - ); - - if (authenticated) { - await this.performLazyEncryptionMigration(userId); + if (!this.userKeys.hasUserDEK(userId)) { + await this.userKeys.createUserDEK(userId); } - - return authenticated; + return this.ensureUserDEK(userId); } async authenticateUser( userId: string, password: string, - deviceType?: DeviceType, + _deviceType?: DeviceType, ): Promise { - const sessionDurationMs = - deviceType === "desktop" || deviceType === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; + // Password-wrapped legacy DEKs can only be recovered while the password + // is in hand, so login is where those users migrate to the v3 wrap. + const migrated = await migratePasswordUserAtLogin(userId, password); - const authenticated = await this.userCrypto.authenticateUser( - userId, - password, - sessionDurationMs, - ); - - if (authenticated) { - await this.performLazyEncryptionMigration(userId); + if (!migrated && !this.userKeys.hasUserDEK(userId)) { + const raw = getCurrentSettingValue(`user_encrypted_dek_${userId}`); + if (raw) { + // A legacy wrap exists but the password could not unwrap it. + return false; + } + await this.userKeys.createUserDEK(userId); } - return authenticated; - } - - async setupWebAuthnUserEncryption(userId: string): Promise { - await this.userCrypto.setupWebAuthnUserEncryption(userId); + return this.ensureUserDEK(userId); } async authenticateWebAuthnUser( userId: string, - deviceType?: DeviceType, + _deviceType?: DeviceType, ): Promise { - const sessionDurationMs = - deviceType === "desktop" || deviceType === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - - const authenticated = await this.userCrypto.authenticateWebAuthnUser( - userId, - sessionDurationMs, - ); - - if (authenticated) { - await this.performLazyEncryptionMigration(userId); - } - - return authenticated; - } - - async convertToOIDCEncryption(userId: string): Promise { - await this.userCrypto.convertToOIDCEncryption(userId); + return this.ensureUserDEK(userId); } private async performLazyEncryptionMigration(userId: string): Promise { @@ -190,37 +188,7 @@ class AuthManager { return; } - const { getSqlite, saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - - const sqlite = getSqlite(); - - const migrationResult = await DataCrypto.migrateUserSensitiveFields( - userId, - userDataKey, - sqlite, - ); - - if (migrationResult.migrated) { - await saveMemoryDatabaseToFile(); - } - - try { - const { CredentialSystemEncryptionMigration } = - await import("./credential-system-encryption-migration.js"); - const credMigration = new CredentialSystemEncryptionMigration(); - const credResult = await credMigration.migrateUserCredentials(userId); - - if (credResult.migrated > 0) { - await saveMemoryDatabaseToFile(); - } - } catch (error) { - databaseLogger.warn("Credential migration failed during login", { - operation: "login_credential_migration_failed", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - } + await DataCrypto.migrateCurrentUserSensitiveFields(userId, userDataKey); } catch (error) { databaseLogger.error("Lazy encryption migration failed", error, { operation: "lazy_encryption_migration_error", @@ -234,27 +202,6 @@ class AuthManager { return Buffer.from(`${userId}:${sessionId || ""}`, "utf8"); } - private async wrapUserDataKey( - userId: string, - sessionId: string | undefined, - dataKey: Buffer, - ): Promise { - const encryptionKey = await this.systemCrypto.getEncryptionKey(); - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); - cipher.setAAD(this.getDataKeyAAD(userId, sessionId)); - - const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]); - const tag = cipher.getAuthTag(); - - return { - version: "v1", - iv: iv.toString("base64url"), - tag: tag.toString("base64url"), - data: encrypted.toString("base64url"), - }; - } - private async unwrapUserDataKey( userId: string, sessionId: string | undefined, @@ -281,41 +228,11 @@ class AuthManager { ]); } - private async addWrappedDataKey(payload: JWTPayload): Promise { - if (payload.pendingTOTP) { - return; - } - - const dataKey = this.userCrypto.getUserDataKey(payload.userId); - if (!dataKey) { - return; - } - - payload.dataKeyWrap = await this.wrapUserDataKey( - payload.userId, - payload.sessionId, - dataKey, - ); - } - - private async restoreDataKeyFromPayload( - payload: JWTPayload, - sessionExpiresAt?: string, - ): Promise { - if ( - !payload.dataKeyWrap || - this.userCrypto.getUserDataKey(payload.userId) - ) { - return; - } - - const expiresAt = sessionExpiresAt - ? new Date(sessionExpiresAt).getTime() - : payload.exp - ? payload.exp * 1000 - : Date.now(); - - if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + // Upgrade shim, remove in the release after 2.5: tokens issued before the + // v3 key wrap carried the user's DEK wrapped with the system ENCRYPTION_KEY. + // Adopting it here silently migrates password users with live sessions. + private async migrateDataKeyFromPayload(payload: JWTPayload): Promise { + if (!payload.dataKeyWrap || this.userKeys.hasUserDEK(payload.userId)) { return; } @@ -325,11 +242,10 @@ class AuthManager { payload.sessionId, payload.dataKeyWrap, ); - this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt); - dataKey.fill(0); + await adoptRecoveredDEK(payload.userId, dataKey); } catch (error) { - databaseLogger.warn("Failed to restore data key from session token", { - operation: "session_data_key_restore_failed", + databaseLogger.warn("Failed to migrate data key from session token", { + operation: "session_data_key_migrate_failed", userId: payload.userId, sessionId: payload.sessionId, error: error instanceof Error ? error.message : "Unknown error", @@ -345,14 +261,17 @@ class AuthManager { rememberMe?: boolean; deviceType?: DeviceType; deviceInfo?: string; + oidcSub?: string | null; + oidcSid?: string | null; + ssoProviderId?: number | null; } = {}, ): Promise { const jwtSecret = await this.systemCrypto.getJWTSecret(); - const timeoutRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'") - .get() as { value: string } | undefined; - const defaultExpiry = `${timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24}h`; + const timeoutValue = await createCurrentSettingsRepository().get( + "session_timeout_hours", + ); + const defaultExpiry = `${timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24}h`; let expiresIn = options.expiresIn; if (!expiresIn && !options.pendingTOTP) { @@ -373,7 +292,6 @@ class AuthManager { if (!options.pendingTOTP && options.deviceType && options.deviceInfo) { const sessionId = nanoid(); payload.sessionId = sessionId; - await this.addWrappedDataKey(payload); const token = jwt.sign(payload, jwtSecret, { expiresIn, @@ -385,31 +303,19 @@ class AuthManager { const createdAt = now.toISOString(); try { - await db.insert(sessions).values({ + await createCurrentSessionRepository().create({ id: sessionId, userId, jwtToken: token, deviceType: options.deviceType, deviceInfo: options.deviceInfo, + oidcSub: options.oidcSub ?? null, + oidcSid: options.oidcSid ?? null, + ssoProviderId: options.ssoProviderId ?? null, createdAt, expiresAt, lastActiveAt: createdAt, }); - - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - databaseLogger.error( - "Failed to save database after session creation", - saveError, - { - operation: "session_create_db_save_failed", - sessionId, - }, - ); - } } catch (error) { databaseLogger.error("Failed to create session", error, { operation: "session_create_failed", @@ -421,7 +327,6 @@ class AuthManager { return token; } - await this.addWrappedDataKey(payload); return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions); } @@ -454,13 +359,11 @@ class AuthManager { if (payload.sessionId) { try { - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, payload.sessionId)) - .limit(1); + const sessionRecord = await createCurrentSessionRepository().findById( + payload.sessionId, + ); - if (sessionRecords.length === 0) { + if (!sessionRecord) { databaseLogger.warn("Session not found during JWT verification", { operation: "jwt_verify_session_not_found", sessionId: payload.sessionId, @@ -469,10 +372,7 @@ class AuthManager { return null; } - await this.restoreDataKeyFromPayload( - payload, - sessionRecords[0].expiresAt, - ); + await this.migrateDataKeyFromPayload(payload); } catch (dbError) { databaseLogger.error( "Failed to check session in database during JWT verification", @@ -485,7 +385,7 @@ class AuthManager { return null; } } else { - await this.restoreDataKeyFromPayload(payload); + await this.migrateDataKeyFromPayload(payload); } return payload; } catch (error) { @@ -502,51 +402,26 @@ class AuthManager { userId: string, sessionId: string, ): Promise<{ token: string; maxAge: number } | null> { - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)) - .limit(1); + const sessionRecord = + await createCurrentSessionRepository().findById(sessionId); - if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) { + if (!sessionRecord || sessionRecord.userId !== userId) { return null; } - const expiresAt = new Date(sessionRecords[0].expiresAt).getTime(); + const expiresAt = new Date(sessionRecord.expiresAt).getTime(); const maxAge = expiresAt - Date.now(); if (!Number.isFinite(maxAge) || maxAge <= 0) { return null; } const payload: JWTPayload = { userId, sessionId }; - await this.addWrappedDataKey(payload); const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), { expiresIn: Math.ceil(maxAge / 1000), } as jwt.SignOptions); - await db - .update(sessions) - .set({ - jwtToken: token, - lastActiveAt: new Date().toISOString(), - }) - .where(eq(sessions.id, sessionId)); - - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - databaseLogger.error( - "Failed to save database after session token refresh", - saveError, - { - operation: "session_token_refresh_db_save_failed", - sessionId, - }, - ); - } + await createCurrentSessionRepository().updateToken(sessionId, token); return { token, maxAge }; } @@ -566,22 +441,7 @@ class AuthManager { sessionId, }); - await db.delete(sessions).where(eq(sessions.id, sessionId)); - - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - databaseLogger.error( - "Failed to save database after session revocation", - saveError, - { - operation: "session_revoke_db_save_failed", - sessionId, - }, - ); - } + await createCurrentSessionRepository().revoke(sessionId); return true; } catch (error) { @@ -598,10 +458,8 @@ class AuthManager { exceptSessionId?: string, ): Promise { try { - const userSessions = await db - .select() - .from(sessions) - .where(eq(sessions.userId, userId)); + const sessionRepository = createCurrentSessionRepository(); + const userSessions = await sessionRepository.listByUserId(userId); const deletedCount = userSessions.filter( (s) => !exceptSessionId || s.id !== exceptSessionId, @@ -613,33 +471,7 @@ class AuthManager { sessionCount: deletedCount, }); - if (exceptSessionId) { - await db - .delete(sessions) - .where( - and( - eq(sessions.userId, userId), - sql`${sessions.id} != ${exceptSessionId}`, - ), - ); - } else { - await db.delete(sessions).where(eq(sessions.userId, userId)); - } - - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - databaseLogger.error( - "Failed to save database after revoking all user sessions", - saveError, - { - operation: "user_sessions_revoke_db_save_failed", - userId, - }, - ); - } + await sessionRepository.revokeAllForUser(userId, exceptSessionId); return deletedCount; } catch (error) { @@ -651,12 +483,58 @@ class AuthManager { } } - async cleanupExpiredSessions(): Promise { + async revokeSessionsByOidc(params: { + ssoProviderId?: number | null; + sub?: string | null; + sid?: string | null; + }): Promise { + const { ssoProviderId, sub, sid } = params; + if (!sub && !sid) return 0; + try { - const expiredSessions = await db + const conditions = [ + sid ? eq(sessions.oidcSid, sid) : eq(sessions.oidcSub, sub!), + ]; + if (ssoProviderId != null) + conditions.push(eq(sessions.ssoProviderId, ssoProviderId)); + + const db = getDb(); + const matched = await db .select() .from(sessions) - .where(sql`${sessions.expiresAt} < datetime('now')`); + .where(conditions.length === 1 ? conditions[0] : and(...conditions)); + + if (matched.length === 0) return 0; + + const matchedIds = matched.map((s) => s.id); + const affectedUsers = new Set(matched.map((s) => s.userId)); + + await db.delete(sessions).where(inArray(sessions.id, matchedIds)); + + authLogger.info("Sessions revoked via OIDC back-channel logout", { + operation: "oidc_backchannel_logout", + ssoProviderId, + sessionCount: matchedIds.length, + }); + + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); + await saveMemoryDatabaseToFile(); + + return matchedIds.length; + } catch (error) { + databaseLogger.error("Failed to revoke sessions via OIDC", error, { + operation: "oidc_backchannel_logout_failed", + }); + throw error; + } + } + + async cleanupExpiredSessions(): Promise { + try { + const sessionRepository = createCurrentSessionRepository(); + const now = new Date(); + const expiredSessions = await sessionRepository.listExpired(now); const expiredCount = expiredSessions.length; @@ -664,35 +542,7 @@ class AuthManager { return 0; } - await db - .delete(sessions) - .where(sql`${sessions.expiresAt} < datetime('now')`); - - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - databaseLogger.error( - "Failed to save database after cleaning up expired sessions", - saveError, - { - operation: "sessions_cleanup_db_save_failed", - }, - ); - } - - const affectedUsers = new Set(expiredSessions.map((s) => s.userId)); - for (const userId of affectedUsers) { - const remainingSessions = await db - .select() - .from(sessions) - .where(eq(sessions.userId, userId)); - - if (remainingSessions.length === 0) { - this.userCrypto.logoutUser(userId); - } - } + await sessionRepository.deleteExpired(now); return expiredCount; } catch (error) { @@ -705,8 +555,7 @@ class AuthManager { async getAllSessions(): Promise[]> { try { - const allSessions = await db.select().from(sessions); - return allSessions; + return createCurrentSessionRepository().listAll(); } catch (error) { databaseLogger.error("Failed to get all sessions", error, { operation: "sessions_get_all_failed", @@ -717,11 +566,7 @@ class AuthManager { async getUserSessions(userId: string): Promise[]> { try { - const userSessions = await db - .select() - .from(sessions) - .where(eq(sessions.userId, userId)); - return userSessions; + return createCurrentSessionRepository().listByUserId(userId); } catch (error) { databaseLogger.error("Failed to get user sessions", error, { operation: "sessions_get_user_failed", @@ -760,15 +605,19 @@ class AuthManager { token: string, requireAdmin = false, ): Promise { + if (req.headers[ADMIN_TARGET_USER_HEADER]) { + res.status(403).json({ + error: "Impersonation is not allowed with API key authentication", + code: "IMPERSONATION_NOT_ALLOWED", + }); + return; + } try { const tokenPrefix = token.substring(0, 12); + const apiKeyRepository = createCurrentApiKeyRepository(); - const candidates = await db - .select() - .from(apiKeys) - .where( - and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)), - ); + const candidates = + await apiKeyRepository.listActiveByTokenPrefix(tokenPrefix); if (candidates.length === 0) { res.status(401).json({ error: "Invalid API key" }); @@ -794,21 +643,17 @@ class AuthManager { } if (requireAdmin) { - const { users } = await import("../database/db/schema.js"); - const userRows = await db - .select() - .from(users) - .where(eq(users.id, matchedKey.userId)) - .limit(1); - if (!userRows[0]?.isAdmin) { + const user = await createCurrentUserRepository().findById( + matchedKey.userId, + ); + if (!user?.isAdmin) { res.status(403).json({ error: "Admin access required" }); return; } } - db.update(apiKeys) - .set({ lastUsedAt: new Date().toISOString() }) - .where(eq(apiKeys.id, matchedKey.id)) + apiKeyRepository + .updateLastUsedAt(matchedKey.id, new Date().toISOString()) .then(() => {}) .catch((err) => { databaseLogger.warn("Failed to update API key lastUsedAt", { @@ -819,6 +664,7 @@ class AuthManager { }); req.userId = matchedKey.userId; + req.apiKeyId = matchedKey.id; next(); } catch (error) { databaseLogger.error("API key authentication failed", error, { @@ -866,13 +712,10 @@ class AuthManager { if (payload.sessionId) { try { - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, payload.sessionId)) - .limit(1); + const sessionRepository = createCurrentSessionRepository(); + const session = await sessionRepository.findById(payload.sessionId); - if (sessionRecords.length === 0) { + if (!session) { databaseLogger.warn("Session not found in middleware", { operation: "middleware_session_not_found", sessionId: payload.sessionId, @@ -887,8 +730,6 @@ class AuthManager { }); } - const session = sessionRecords[0]; - const sessionExpiryTime = new Date(session.expiresAt).getTime(); const currentTime = Date.now(); const isExpired = sessionExpiryTime < currentTime; @@ -903,43 +744,12 @@ class AuthManager { difference: currentTime - sessionExpiryTime, }); - db.delete(sessions) - .where(eq(sessions.id, payload.sessionId)) - .then(async () => { - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - - const remainingSessions = await db - .select() - .from(sessions) - .where(eq(sessions.userId, payload.userId)); - - if (remainingSessions.length === 0) { - this.userCrypto.logoutUser(payload.userId); - } - } catch (cleanupError) { - databaseLogger.error( - "Failed to cleanup after expired session", - cleanupError, - { - operation: "expired_session_cleanup_failed", - sessionId: payload.sessionId, - }, - ); - } - }) - .catch((error) => { - databaseLogger.error( - "Failed to delete expired session", - error, - { - operation: "expired_session_delete_failed", - sessionId: payload.sessionId, - }, - ); + sessionRepository.revoke(payload.sessionId).catch((error) => { + databaseLogger.error("Failed to delete expired session", error, { + operation: "expired_session_delete_failed", + sessionId: payload.sessionId, }); + }); return res .clearCookie("jwt", this.getClearCookieOptions(req)) @@ -950,9 +760,8 @@ class AuthManager { }); } - db.update(sessions) - .set({ lastActiveAt: new Date().toISOString() }) - .where(eq(sessions.id, payload.sessionId)) + sessionRepository + .touch(payload.sessionId) .then(() => {}) .catch((error) => { databaseLogger.warn("Failed to update session lastActiveAt", { @@ -973,10 +782,103 @@ class AuthManager { authReq.userId = payload.userId; authReq.sessionId = payload.sessionId; authReq.pendingTOTP = payload.pendingTOTP; + + if (authReq.headers[ADMIN_TARGET_USER_HEADER]) { + const handled = await this.applyAdminImpersonation( + authReq, + res, + payload.userId, + ); + if (handled) return; + } + next(); }; } + /** + * Handle the X-Admin-Target-User header: admins may act on another user's + * data for an allowlisted set of data-plane routes. On success req.userId + * becomes the target user and actingAdminUserId records the real caller. + * Returns true when a response was already sent (request must stop). + */ + private async applyAdminImpersonation( + req: AuthenticatedRequest, + res: Response, + adminUserId: string, + ): Promise { + const rawHeader = req.headers[ADMIN_TARGET_USER_HEADER]; + const targetUserId = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + + if (!targetUserId || targetUserId === adminUserId) { + return false; + } + + const userRepository = createCurrentUserRepository(); + const admin = await userRepository.findById(adminUserId); + if (!admin?.isAdmin) { + databaseLogger.warn("Impersonation attempt by non-admin", { + operation: "admin_impersonation_denied", + userId: adminUserId, + targetUserId, + path: req.originalUrl, + }); + res.status(403).json({ + error: "Admin access required", + code: "IMPERSONATION_DENIED", + }); + return true; + } + + const path = (req.originalUrl || req.url || "").split("?")[0]; + const allowed = IMPERSONATION_PATH_ALLOWLIST.some((pattern) => + pattern.test(path), + ); + if (!allowed) { + res.status(403).json({ + error: "Impersonation is not allowed for this route", + code: "IMPERSONATION_NOT_ALLOWED", + }); + return true; + } + + const target = await userRepository.findById(targetUserId); + if (!target) { + res.status(404).json({ + error: "Target user not found", + code: "TARGET_USER_NOT_FOUND", + }); + return true; + } + + if (!DataCrypto.canUserAccessData(targetUserId)) { + res.status(423).json({ + error: "Target user's data stays locked until their next login", + code: "TARGET_DATA_LOCKED", + }); + return true; + } + + req.userId = targetUserId; + req.actingAdminUserId = adminUserId; + + const { ipAddress, userAgent } = getRequestMeta(req); + void logAudit({ + userId: adminUserId, + username: admin.username, + action: "admin_impersonated_request", + resourceType: "user", + resourceId: targetUserId, + resourceName: target.username, + details: JSON.stringify({ method: req.method, path }), + ipAddress, + userAgent, + success: true, + }); + + return false; + } + createDataAccessMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; @@ -985,8 +887,7 @@ class AuthManager { return res.status(401).json({ error: "Authentication required" }); } - const dataKey = this.userCrypto.getUserDataKey(userId); - authReq.dataKey = dataKey || undefined; + authReq.dataKey = this.userKeys.tryGetUserDEK(userId) ?? undefined; next(); }; } @@ -1033,16 +934,11 @@ class AuthManager { } try { - const { db } = await import("../database/db/index.js"); - const { users } = await import("../database/db/schema.js"); - const { eq } = await import("drizzle-orm"); + const user = await createCurrentUserRepository().findById( + payload.userId, + ); - const user = await db - .select() - .from(users) - .where(eq(users.id, payload.userId)); - - if (!user || user.length === 0 || !user[0].isAdmin) { + if (!user?.isAdmin) { databaseLogger.warn( "Non-admin user attempted to access admin endpoint", { @@ -1072,92 +968,40 @@ class AuthManager { } async logoutUser(userId: string, sessionId?: string): Promise { - if (sessionId) { - try { - await db.delete(sessions).where(eq(sessions.id, sessionId)); + const sessionRepository = createCurrentSessionRepository(); - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - databaseLogger.error( - "Failed to save database after logout", - saveError, - { - operation: "logout_db_save_failed", - userId, - sessionId, - }, - ); - } - - const remainingSessions = await db - .select() - .from(sessions) - .where(eq(sessions.userId, userId)); - - if (remainingSessions.length === 0) { - this.userCrypto.logoutUser(userId); - } else { - // expected - other sessions still active, keep user crypto state - } - } catch (error) { - databaseLogger.error("Failed to delete session on logout", error, { - operation: "session_delete_logout_failed", - userId, - sessionId, - }); + try { + if (sessionId) { + await sessionRepository.revoke(sessionId); + } else { + await sessionRepository.revokeAllForUser(userId); } - } else { - try { - await db.delete(sessions).where(eq(sessions.userId, userId)); - - try { - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch { - // best effort - } - } catch (error) { - databaseLogger.error("Failed to revoke all sessions on logout", error, { - operation: "session_revoke_all_failed", - userId, - }); - } - this.userCrypto.logoutUser(userId); + } catch (error) { + databaseLogger.error("Failed to revoke sessions on logout", error, { + operation: "session_delete_logout_failed", + userId, + sessionId, + }); } } getUserDataKey(userId: string): Buffer | null { - return this.userCrypto.getUserDataKey(userId); + return this.userKeys.tryGetUserDEK(userId); } isUserUnlocked(userId: string): boolean { - return this.userCrypto.isUserUnlocked(userId); + return this.userKeys.tryGetUserDEK(userId) !== null; } async changeUserPassword( userId: string, oldPassword: string, - newPassword: string, + _newPassword?: string, ): Promise { - return await this.userCrypto.changeUserPassword( - userId, - oldPassword, - newPassword, - ); - } - - async resetUserPasswordWithPreservedDEK( - userId: string, - newPassword: string, - ): Promise { - return await this.userCrypto.resetUserPasswordWithPreservedDEK( - userId, - newPassword, - ); + // The DEK is no longer derived from the password; the old password is + // only needed when the user still has a legacy password-wrapped key. + await migratePasswordUserAtLogin(userId, oldPassword); + return this.userKeys.tryGetUserDEK(userId) !== null; } async isTrustedDevice( @@ -1165,38 +1009,29 @@ class AuthManager { deviceFingerprint: string, ): Promise { try { - const device = await db - .select() - .from(trustedDevices) - .where( - and( - eq(trustedDevices.userId, userId), - eq(trustedDevices.deviceFingerprint, deviceFingerprint), - ), - ) - .limit(1); + const trustedDeviceRepository = createCurrentTrustedDeviceRepository(); + const device = await trustedDeviceRepository.findByUserAndFingerprint( + userId, + deviceFingerprint, + ); - if (!device || device.length === 0) { + if (!device) { return false; } const now = new Date(); - const expiresAt = new Date(device[0].expiresAt); + const expiresAt = new Date(device.expiresAt); if (now > expiresAt) { await this.removeTrustedDevice(userId, deviceFingerprint); return false; } - await db - .update(trustedDevices) - .set({ lastUsedAt: now.toISOString() }) - .where( - and( - eq(trustedDevices.userId, userId), - eq(trustedDevices.deviceFingerprint, deviceFingerprint), - ), - ); + await trustedDeviceRepository.touch( + userId, + deviceFingerprint, + now.toISOString(), + ); return true; } catch (error) { @@ -1214,56 +1049,26 @@ class AuthManager { const now = new Date(); const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); - const existingDevice = await db - .select() - .from(trustedDevices) - .where( - and( - eq(trustedDevices.userId, userId), - eq(trustedDevices.deviceFingerprint, deviceFingerprint), - ), - ) - .limit(1); - - if (existingDevice && existingDevice.length > 0) { - await db - .update(trustedDevices) - .set({ - expiresAt: expiresAt.toISOString(), - lastUsedAt: now.toISOString(), - }) - .where( - and( - eq(trustedDevices.userId, userId), - eq(trustedDevices.deviceFingerprint, deviceFingerprint), - ), - ); - } else { - await db.insert(trustedDevices).values({ - id: nanoid(), - userId, - deviceFingerprint, - deviceType, - deviceInfo, - createdAt: now.toISOString(), - expiresAt: expiresAt.toISOString(), - lastUsedAt: now.toISOString(), - }); - } + await createCurrentTrustedDeviceRepository().upsert({ + id: nanoid(), + userId, + deviceFingerprint, + deviceType, + deviceInfo, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + lastUsedAt: now.toISOString(), + }); } async removeTrustedDevice( userId: string, deviceFingerprint: string, ): Promise { - await db - .delete(trustedDevices) - .where( - and( - eq(trustedDevices.userId, userId), - eq(trustedDevices.deviceFingerprint, deviceFingerprint), - ), - ); + await createCurrentTrustedDeviceRepository().deleteByUserAndFingerprint( + userId, + deviceFingerprint, + ); } } diff --git a/src/backend/utils/cors-config.ts b/src/backend/utils/cors-config.ts index 7a96d889..582d454d 100644 --- a/src/backend/utils/cors-config.ts +++ b/src/backend/utils/cors-config.ts @@ -36,6 +36,7 @@ export function createCorsMiddleware( "User-Agent", "X-Electron-App", "Cache-Control", + "x-admin-target-user", ...extraHeaders, ]; diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts deleted file mode 100644 index a749ae29..00000000 --- a/src/backend/utils/credential-system-encryption-migration.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { db } from "../database/db/index.js"; -import { sshCredentials, sharedCredentials } from "../database/db/schema.js"; -import { eq, and, or, isNull } from "drizzle-orm"; -import { DataCrypto } from "./data-crypto.js"; -import { SystemCrypto } from "./system-crypto.js"; -import { FieldCrypto } from "./field-crypto.js"; -import { databaseLogger } from "./logger.js"; - -export class CredentialSystemEncryptionMigration { - async migrateUserCredentials(userId: string): Promise<{ - migrated: number; - failed: number; - skipped: number; - }> { - try { - const userDEK = DataCrypto.getUserDataKey(userId); - if (!userDEK) { - throw new Error("User must be logged in to migrate credentials"); - } - - const systemCrypto = SystemCrypto.getInstance(); - const CSKEK = await systemCrypto.getCredentialSharingKey(); - - const credentials = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, userId), - or( - isNull(sshCredentials.systemPassword), - isNull(sshCredentials.systemKey), - isNull(sshCredentials.systemKeyPassword), - ), - ), - ); - - let migrated = 0; - let failed = 0; - const skipped = 0; - - for (const cred of credentials) { - try { - const plainPassword = cred.password - ? FieldCrypto.decryptField( - cred.password, - userDEK, - cred.id.toString(), - "password", - ) - : null; - - const plainKey = cred.key - ? FieldCrypto.decryptField( - cred.key, - userDEK, - cred.id.toString(), - "key", - ) - : null; - - const plainKeyPassword = cred.keyPassword - ? FieldCrypto.decryptField( - cred.keyPassword, - userDEK, - cred.id.toString(), - "keyPassword", - ) - : null; - - const systemPassword = plainPassword - ? FieldCrypto.encryptField( - plainPassword, - CSKEK, - cred.id.toString(), - "password", - ) - : null; - - const systemKey = plainKey - ? FieldCrypto.encryptField( - plainKey, - CSKEK, - cred.id.toString(), - "key", - ) - : null; - - const systemKeyPassword = plainKeyPassword - ? FieldCrypto.encryptField( - plainKeyPassword, - CSKEK, - cred.id.toString(), - "key_password", - ) - : null; - - await db - .update(sshCredentials) - .set({ - systemPassword, - systemKey, - systemKeyPassword, - updatedAt: new Date().toISOString(), - }) - .where(eq(sshCredentials.id, cred.id)); - - await db - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.originalCredentialId, cred.id)); - - migrated++; - } catch (error) { - databaseLogger.warn( - `Skipping credential migration for credential ${cred.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - { - operation: "credential_migration_skip", - credentialId: cred.id, - userId, - }, - ); - failed++; - } - } - return { migrated, failed, skipped }; - } catch (error) { - databaseLogger.warn("Credential system encryption migration incomplete", { - operation: "credential_migration_incomplete", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - throw error; - } - } -} diff --git a/src/backend/utils/crypto-migration/dek-migration.ts b/src/backend/utils/crypto-migration/dek-migration.ts new file mode 100644 index 00000000..27ebb903 --- /dev/null +++ b/src/backend/utils/crypto-migration/dek-migration.ts @@ -0,0 +1,344 @@ +import crypto from "crypto"; +import { databaseLogger } from "../logger.js"; +import { SystemCrypto } from "../system-crypto.js"; +import { UserKeyManager } from "../user-keys.js"; +import { + createCurrentSettingsRepository, + createCurrentUserRepository, + getCurrentSettingValue, +} from "../../database/repositories/factory.js"; + +// Legacy (pre-v3) wrap formats, kept only to migrate existing installs. +// Password users: DEK wrapped by PBKDF2(password) using user_kek_salt_*. +// OIDC/WebAuthn users: DEK wrapped by a key derived from the system +// ENCRYPTION_KEY (or *_SYSTEM_SECRET env overrides), so the server can +// unwrap those eagerly at boot. Password wraps need the password (login) +// or the DEK recovered from a live session's JWT. + +interface LegacyEncryptedDEK { + data: string; + iv: string; + tag: string; + algorithm: string; + createdAt: string; +} + +interface LegacyKekSalt { + salt: string; + iterations: number; + algorithm: string; + createdAt: string; +} + +const KEY_LENGTH = 32; + +export function legacySettingsKeys(userId: string): { + passwordWrap: string; + kekSalt: string; + oidcWrap: string; + webauthnWrap: string; +} { + return { + passwordWrap: `user_encrypted_dek_${userId}`, + kekSalt: `user_kek_salt_${userId}`, + oidcWrap: `user_encrypted_dek_oidc_${userId}`, + webauthnWrap: `user_encrypted_dek_webauthn_${userId}`, + }; +} + +function parseJson(raw: string | null): T | null { + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function decryptLegacyDEK(encrypted: LegacyEncryptedDEK, kek: Buffer): Buffer { + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + kek, + Buffer.from(encrypted.iv, "hex"), + ); + decipher.setAuthTag(Buffer.from(encrypted.tag, "hex")); + return Buffer.concat([ + decipher.update(Buffer.from(encrypted.data, "hex")), + decipher.final(), + ]); +} + +function deriveLegacyKEK(password: string, kekSalt: LegacyKekSalt): Buffer { + return crypto.pbkdf2Sync( + password, + Buffer.from(kekSalt.salt, "hex"), + kekSalt.iterations, + KEY_LENGTH, + "sha256", + ); +} + +async function deriveOIDCSystemKey(userId: string): Promise { + if (process.env.OIDC_SYSTEM_SECRET) { + return crypto.pbkdf2Sync( + process.env.OIDC_SYSTEM_SECRET, + Buffer.from(userId, "utf8"), + 100000, + KEY_LENGTH, + "sha256", + ); + } + + const systemSecret = await SystemCrypto.getInstance().getEncryptionKey(); + return Buffer.from( + crypto.hkdfSync( + "sha256", + systemSecret, + Buffer.from(userId, "utf8"), + Buffer.from("termix:oidc-user-kek", "utf8"), + KEY_LENGTH, + ), + ); +} + +async function deriveWebAuthnSystemKey(userId: string): Promise { + const configuredSecret = + process.env.WEBAUTHN_SYSTEM_SECRET || process.env.OIDC_SYSTEM_SECRET; + if (configuredSecret) { + return crypto.pbkdf2Sync( + configuredSecret, + Buffer.from(`webauthn:${userId}`, "utf8"), + 100000, + KEY_LENGTH, + "sha256", + ); + } + + const systemSecret = await SystemCrypto.getInstance().getEncryptionKey(); + return Buffer.from( + crypto.hkdfSync( + "sha256", + systemSecret, + Buffer.from(userId, "utf8"), + Buffer.from("termix:webauthn-user-kek", "utf8"), + KEY_LENGTH, + ), + ); +} + +function deriveLegacyDefaultKey( + userId: string, + type: "oidc" | "webauthn", +): Buffer { + const secret = + type === "oidc" + ? "termix-oidc-system-secret-default" + : "termix-webauthn-system-secret-default"; + const salt = Buffer.from( + type === "oidc" ? userId : `webauthn:${userId}`, + "utf8", + ); + return crypto.pbkdf2Sync(secret, salt, 100000, KEY_LENGTH, "sha256"); +} + +async function tryUnwrapSystemWrapped( + userId: string, + raw: string | null, + type: "oidc" | "webauthn", +): Promise { + const encrypted = parseJson(raw); + if (!encrypted?.data || !encrypted.iv || !encrypted.tag) return null; + + const candidates = [ + type === "oidc" + ? await deriveOIDCSystemKey(userId) + : await deriveWebAuthnSystemKey(userId), + deriveLegacyDefaultKey(userId, type), + ]; + + for (const key of candidates) { + try { + const dek = decryptLegacyDEK(encrypted, key); + if (dek.length === KEY_LENGTH) return dek; + } catch { + // wrong key for this wrap; try the next candidate + } + } + + return null; +} + +function hasAnyLegacyWrap(userId: string): boolean { + const keys = legacySettingsKeys(userId); + return ( + getCurrentSettingValue(keys.passwordWrap) !== null || + getCurrentSettingValue(keys.kekSalt) !== null || + getCurrentSettingValue(keys.oidcWrap) !== null || + getCurrentSettingValue(keys.webauthnWrap) !== null + ); +} + +export async function deleteLegacyWraps(userId: string): Promise { + const keys = legacySettingsKeys(userId); + const settings = createCurrentSettingsRepository(); + await settings.delete(keys.passwordWrap); + await settings.delete(keys.kekSalt); + await settings.delete(keys.oidcWrap); + await settings.delete(keys.webauthnWrap); +} + +// Server-side unwrap for one user, in preference order. The primary +// user_encrypted_dek_* slot is only tried when no KEK salt exists: with a +// salt present it is password-wrapped and unrecoverable here. +async function unwrapServerSide(userId: string): Promise { + const keys = legacySettingsKeys(userId); + + const fromOidc = await tryUnwrapSystemWrapped( + userId, + getCurrentSettingValue(keys.oidcWrap), + "oidc", + ); + if (fromOidc) return fromOidc; + + const fromWebauthn = await tryUnwrapSystemWrapped( + userId, + getCurrentSettingValue(keys.webauthnWrap), + "webauthn", + ); + if (fromWebauthn) return fromWebauthn; + + if (getCurrentSettingValue(keys.kekSalt) === null) { + const fromPrimary = await tryUnwrapSystemWrapped( + userId, + getCurrentSettingValue(keys.passwordWrap), + "oidc", + ); + if (fromPrimary) return fromPrimary; + } + + return null; +} + +export interface BootDekMigrationSummary { + totalUsers: number; + alreadyMigrated: number; + migrated: number; + created: number; + pendingPasswordLogin: number; +} + +export interface BootDekMigrationOptions { + cleanupLegacy?: boolean; +} + +export async function runBootDekMigration( + options: BootDekMigrationOptions = {}, +): Promise { + const cleanupLegacy = options.cleanupLegacy ?? false; + const userKeys = UserKeyManager.getInstance(); + const users = await createCurrentUserRepository().listAll(); + + const summary: BootDekMigrationSummary = { + totalUsers: users.length, + alreadyMigrated: 0, + migrated: 0, + created: 0, + pendingPasswordLogin: 0, + }; + + for (const user of users) { + if (userKeys.hasUserDEK(user.id)) { + summary.alreadyMigrated++; + if (cleanupLegacy && hasAnyLegacyWrap(user.id)) { + await deleteLegacyWraps(user.id); + } + continue; + } + + const dek = await unwrapServerSide(user.id); + if (dek) { + await userKeys.persistDEK(user.id, dek); + summary.migrated++; + if (cleanupLegacy) { + await deleteLegacyWraps(user.id); + } + continue; + } + + if (hasAnyLegacyWrap(user.id)) { + summary.pendingPasswordLogin++; + continue; + } + + await userKeys.createUserDEK(user.id); + summary.created++; + } + + databaseLogger.info("User key migration pass finished", { + operation: "dek_migration_boot", + ...summary, + }); + + return summary; +} + +// Password login is the only remaining way to recover a password-wrapped DEK. +export async function migratePasswordUserAtLogin( + userId: string, + password: string, +): Promise { + const userKeys = UserKeyManager.getInstance(); + + if (userKeys.hasUserDEK(userId)) { + if (hasAnyLegacyWrap(userId)) { + await deleteLegacyWraps(userId); + } + return true; + } + + const keys = legacySettingsKeys(userId); + const kekSalt = parseJson( + getCurrentSettingValue(keys.kekSalt), + ); + const encrypted = parseJson( + getCurrentSettingValue(keys.passwordWrap), + ); + if (!kekSalt?.salt || !encrypted?.data) return false; + + let kek: Buffer | null = null; + try { + kek = deriveLegacyKEK(password, kekSalt); + const dek = decryptLegacyDEK(encrypted, kek); + if (dek.length !== KEY_LENGTH) return false; + + await userKeys.persistDEK(userId, dek); + await deleteLegacyWraps(userId); + + databaseLogger.info("Migrated password-wrapped user key at login", { + operation: "dek_migration_login", + userId, + }); + return true; + } catch { + return false; + } finally { + kek?.fill(0); + } +} + +// Adopt a DEK recovered from a live session (the legacy JWT dataKeyWrap). +export async function adoptRecoveredDEK( + userId: string, + dek: Buffer, +): Promise { + const userKeys = UserKeyManager.getInstance(); + if (userKeys.hasUserDEK(userId)) return; + + await userKeys.persistDEK(userId, dek); + await deleteLegacyWraps(userId); + + databaseLogger.info("Migrated user key from active session", { + operation: "dek_migration_session", + userId, + }); +} diff --git a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts new file mode 100644 index 00000000..64cbb058 --- /dev/null +++ b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts @@ -0,0 +1,64 @@ +import { databaseLogger } from "../logger.js"; +import { DatabaseSaveTrigger } from "../database-save-trigger.js"; +import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js"; + +interface SqliteLike { + prepare(sql: string): { + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + run(...params: unknown[]): unknown; + }; + exec(sql: string): unknown; +} + +function tableHasColumn( + sqlite: SqliteLike, + table: string, + column: string, +): boolean { + const rows = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name: string; + }>; + return rows.some((row) => row.name === column); +} + +function dropColumnIfExists( + sqlite: SqliteLike, + table: string, + column: string, +): boolean { + if (!tableHasColumn(sqlite, table, column)) return false; + sqlite.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`); + return true; +} + +// One-time cleanup of the pre-2.5 sharing machinery: the +// CREDENTIAL_SHARING_KEY shadow columns are dropped. Pending share copies are +// no longer re-created here; the shared_host_secrets migration rebuilds every +// active share from the live host_access grants instead. +export async function runLegacySharedCredentialCleanup(): Promise<{ + columnsDropped: number; +}> { + const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; + const result = { columnsDropped: 0 }; + + for (const [table, column] of [ + ["ssh_credentials", "system_password"], + ["ssh_credentials", "system_key"], + ["ssh_credentials", "system_key_password"], + ] as const) { + if (dropColumnIfExists(sqlite, table, column)) { + result.columnsDropped++; + } + } + + if (result.columnsDropped) { + await DatabaseSaveTrigger.forceSave("legacy_share_cleanup"); + databaseLogger.info("Legacy shared-credential cleanup finished", { + operation: "legacy_share_cleanup", + ...result, + }); + } + + return result; +} diff --git a/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts new file mode 100644 index 00000000..963bdbeb --- /dev/null +++ b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts @@ -0,0 +1,121 @@ +import { databaseLogger } from "../logger.js"; +import { DataCrypto } from "../data-crypto.js"; +import { DatabaseSaveTrigger } from "../database-save-trigger.js"; +import { + createCurrentSettingsRepository, + getCurrentRepositorySqlite, +} from "../../database/repositories/factory.js"; +import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js"; + +const MIGRATION_FLAG = "shared_host_secrets_migrated_v1"; + +interface GrantRow { + hostAccessId: number; + hostId: number; + userId: string | null; + roleId: number | null; + ownerId: string; +} + +// One-time 2.5.x migration: rebuild every active share as per-protocol +// secret snapshots in shared_host_secrets, then drop the SSH-only +// shared_credentials table it replaces. Grants whose owner or recipient DEK +// is unavailable are skipped (logged), never fatal to boot. +export async function runSharedHostSecretsMigration(): Promise<{ + snapshotted: number; + skipped: number; +} | null> { + const settingsRepository = createCurrentSettingsRepository(); + + try { + if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") { + return null; + } + } catch { + return null; + } + + const sqlite = getCurrentRepositorySqlite(); + const result = { snapshotted: 0, skipped: 0 }; + + try { + const grants = sqlite + .prepare( + `SELECT ha.id AS hostAccessId, ha.host_id AS hostId, + ha.user_id AS userId, ha.role_id AS roleId, + h.user_id AS ownerId + FROM host_access ha + JOIN ssh_data h ON h.id = ha.host_id + WHERE ha.expires_at IS NULL OR ha.expires_at >= ?`, + ) + .all(new Date().toISOString()) as GrantRow[]; + + const manager = SharedHostSecretsManager.getInstance(); + + for (const grant of grants) { + const targetUserIds = grant.userId + ? [grant.userId] + : grant.roleId + ? ( + sqlite + .prepare(`SELECT user_id FROM user_roles WHERE role_id = ?`) + .all(grant.roleId) as Array<{ user_id: string }> + ).map((row) => row.user_id) + : []; + + for (const targetUserId of targetUserIds) { + if (targetUserId === grant.ownerId) continue; + + if ( + !DataCrypto.canUserAccessData(grant.ownerId) || + !DataCrypto.canUserAccessData(targetUserId) + ) { + result.skipped++; + databaseLogger.warn( + "Skipping share snapshot migration: missing DEK", + { + operation: "shared_host_secrets_migration_skip", + hostAccessId: grant.hostAccessId, + targetUserId, + }, + ); + continue; + } + + try { + await manager.snapshotForUser( + grant.hostAccessId, + grant.hostId, + targetUserId, + grant.ownerId, + ); + result.snapshotted++; + } catch (error) { + result.skipped++; + databaseLogger.warn("Failed to migrate share snapshot", { + operation: "shared_host_secrets_migration_failed", + hostAccessId: grant.hostAccessId, + targetUserId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + } + + sqlite.exec("DROP TABLE IF EXISTS shared_credentials"); + await settingsRepository.set(MIGRATION_FLAG, "done"); + await DatabaseSaveTrigger.forceSave("shared_host_secrets_migration"); + + databaseLogger.info("Shared host secrets migration finished", { + operation: "shared_host_secrets_migration", + ...result, + }); + + return result; + } catch (error) { + databaseLogger.error("Shared host secrets migration failed", error, { + operation: "shared_host_secrets_migration", + }); + return result; + } +} diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index f129b443..a92de14b 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -1,26 +1,20 @@ import { FieldCrypto } from "./field-crypto.js"; import { LazyFieldEncryption } from "./lazy-field-encryption.js"; -import { UserCrypto } from "./user-crypto.js"; +import { UserKeyManager } from "./user-keys.js"; +import { DatabaseSaveTrigger } from "./database-save-trigger.js"; import { databaseLogger } from "./logger.js"; - -interface DatabaseInstance { - prepare: (sql: string) => { - all: (param?: unknown) => DatabaseRecord[]; - get: (param?: unknown) => DatabaseRecord; - run: (...params: unknown[]) => unknown; - }; -} - -interface DatabaseRecord { - id: number | string; - [key: string]: unknown; -} +import { + createCurrentUserEncryptionMigrationStore, + RawSqliteUserEncryptionMigrationStore, + type LegacyDatabaseInstance, + type UserEncryptionMigrationStore, +} from "./user-encryption-migration-store.js"; class DataCrypto { - private static userCrypto: UserCrypto; + private static userKeys: UserKeyManager; static initialize() { - this.userCrypto = UserCrypto.getInstance(); + this.userKeys = UserKeyManager.getInstance(); } static encryptRecord>( @@ -30,14 +24,14 @@ class DataCrypto { userDataKey: Buffer, ): T { const encryptedRecord: Record = { ...record }; - const recordId = record.id || "temp-" + Date.now(); + const recordId = String(record.id || "temp-" + Date.now()); for (const [fieldName, value] of Object.entries(record)) { if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) { encryptedRecord[fieldName] = FieldCrypto.encryptField( value as string, userDataKey, - recordId as string, + recordId, fieldName, ); } @@ -55,14 +49,14 @@ class DataCrypto { if (!record) return record; const decryptedRecord: Record = { ...record }; - const recordId = record.id; + const recordId = String(record.id); for (const [fieldName, value] of Object.entries(record)) { if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) { decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue( value as string, userDataKey, - recordId as string, + recordId, fieldName, ); } @@ -86,7 +80,34 @@ class DataCrypto { static async migrateUserSensitiveFields( userId: string, userDataKey: Buffer, - db: DatabaseInstance, + db: LegacyDatabaseInstance, + ): Promise<{ + migrated: boolean; + migratedTables: string[]; + migratedFieldsCount: number; + }> { + try { + const store = new RawSqliteUserEncryptionMigrationStore(db); + return await this.migrateUserSensitiveFieldsInStore( + userId, + userDataKey, + store, + ); + } catch (error) { + databaseLogger.error("User sensitive fields migration failed", error, { + operation: "user_sensitive_migration_failed", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }); + + return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; + } + } + + private static async migrateUserSensitiveFieldsInStore( + userId: string, + userDataKey: Buffer, + store: UserEncryptionMigrationStore, ): Promise<{ migrated: boolean; migratedTables: string[]; @@ -101,16 +122,14 @@ class DataCrypto { await LazyFieldEncryption.checkUserNeedsMigration( userId, userDataKey, - db, + store, ); if (!needsMigration) { return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; } - const sshDataRecords = db - .prepare("SELECT * FROM ssh_data WHERE user_id = ?") - .all(userId) as DatabaseRecord[]; + const sshDataRecords = store.listHostRecords(userId); for (const record of sshDataRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data"); @@ -123,22 +142,7 @@ class DataCrypto { ); if (needsUpdate) { - const updateQuery = ` - UPDATE ssh_data - SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `; - db.prepare(updateQuery).run( - updatedRecord.password || null, - updatedRecord.key || null, - updatedRecord.key_password || null, - updatedRecord.key_type || null, - updatedRecord.autostart_password || null, - updatedRecord.autostart_key || null, - updatedRecord.autostart_key_password || null, - updatedRecord.sudo_password || null, - record.id, - ); + store.updateHostSensitiveFields(record.id, updatedRecord); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("ssh_data")) { @@ -148,9 +152,7 @@ class DataCrypto { } } - const sshCredentialsRecords = db - .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") - .all(userId) as DatabaseRecord[]; + const sshCredentialsRecords = store.listCredentialRecords(userId); for (const record of sshCredentialsRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials"); @@ -163,20 +165,7 @@ class DataCrypto { ); if (needsUpdate) { - const updateQuery = ` - UPDATE ssh_credentials - SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `; - db.prepare(updateQuery).run( - updatedRecord.password || null, - updatedRecord.key || null, - updatedRecord.key_password || null, - updatedRecord.private_key || null, - updatedRecord.public_key || null, - updatedRecord.key_type || null, - record.id, - ); + store.updateCredentialSensitiveFields(record.id, updatedRecord); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("ssh_credentials")) { @@ -186,9 +175,7 @@ class DataCrypto { } } - const userRecord = db - .prepare("SELECT * FROM users WHERE id = ?") - .get(userId) as DatabaseRecord | undefined; + const userRecord = store.getUserRecord(userId); if (userRecord) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("users"); @@ -201,18 +188,7 @@ class DataCrypto { ); if (needsUpdate) { - const updateQuery = ` - UPDATE users - SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ? - WHERE id = ? - `; - db.prepare(updateQuery).run( - updatedRecord.totp_secret || null, - updatedRecord.totp_backup_codes || null, - updatedRecord.client_secret || null, - updatedRecord.oidc_identifier || null, - userId, - ); + store.updateUserSensitiveFields(userId, updatedRecord); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("users")) { @@ -234,188 +210,35 @@ class DataCrypto { } } - static getUserDataKey(userId: string): Buffer | null { - return this.userCrypto.getUserDataKey(userId); + static async migrateCurrentUserSensitiveFields( + userId: string, + userDataKey: Buffer, + ): Promise<{ + migrated: boolean; + migratedTables: string[]; + migratedFieldsCount: number; + }> { + const result = await this.migrateUserSensitiveFieldsInStore( + userId, + userDataKey, + await createCurrentUserEncryptionMigrationStore(), + ); + + if (result.migrated) { + await DatabaseSaveTrigger.forceSave( + "user_sensitive_migration_explicit_save", + ); + } + + return result; } - static async reencryptUserDataAfterPasswordReset( - userId: string, - newUserDataKey: Buffer, - db: DatabaseInstance, - ): Promise<{ - success: boolean; - reencryptedTables: string[]; - reencryptedFieldsCount: number; - errors: string[]; - }> { - const result = { - success: false, - reencryptedTables: [] as string[], - reencryptedFieldsCount: 0, - errors: [] as string[], - }; - - try { - const tablesToReencrypt = [ - { - table: "ssh_data", - fields: [ - "password", - "key", - "key_password", - "sudo_password", - "autostart_password", - "autostart_key", - "autostart_key_password", - ], - }, - { - table: "ssh_credentials", - fields: [ - "password", - "key", - "private_key", - "public_key", - "key_password", - ], - }, - { - table: "users", - fields: [ - "client_secret", - "totp_secret", - "totp_backup_codes", - "oidc_identifier", - ], - }, - ]; - - for (const { table, fields } of tablesToReencrypt) { - try { - const selectQuery = - table === "users" - ? `SELECT * FROM ${table} WHERE id = ?` - : `SELECT * FROM ${table} WHERE user_id = ?`; - const records = db - .prepare(selectQuery) - .all(userId) as DatabaseRecord[]; - - for (const record of records) { - const recordId = record.id.toString(); - const updatedRecord: DatabaseRecord = { ...record }; - let needsUpdate = false; - - for (const fieldName of fields) { - const fieldValue = record[fieldName]; - - if ( - fieldValue && - typeof fieldValue === "string" && - fieldValue.trim() !== "" - ) { - try { - const reencryptedValue = FieldCrypto.encryptField( - fieldValue, - newUserDataKey, - recordId, - fieldName, - ); - - updatedRecord[fieldName] = reencryptedValue; - needsUpdate = true; - result.reencryptedFieldsCount++; - } catch (error) { - const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`; - result.errors.push(errorMsg); - databaseLogger.warn( - "Field re-encryption failed during password reset", - { - operation: "password_reset_reencrypt_failed", - userId, - table, - recordId, - fieldName, - error: - error instanceof Error - ? error.message - : "Unknown error", - }, - ); - } - } - } - - if (needsUpdate) { - const updateFields = fields.filter( - (field) => updatedRecord[field] !== record[field], - ); - if (updateFields.length > 0) { - const setClause = updateFields - .map((f) => `${f} = ?`) - .join(", "); - const updateQuery = - table === "users" - ? `UPDATE ${table} SET ${setClause} WHERE id = ?` - : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; - const updateValues = updateFields.map( - (field) => updatedRecord[field], - ); - updateValues.push(record.id); - - db.prepare(updateQuery).run(...updateValues); - - if (!result.reencryptedTables.includes(table)) { - result.reencryptedTables.push(table); - } - } - } - } - } catch (tableError) { - const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`; - result.errors.push(errorMsg); - databaseLogger.error( - "Table re-encryption failed during password reset", - tableError, - { - operation: "password_reset_table_reencrypt_failed", - userId, - table, - error: - tableError instanceof Error - ? tableError.message - : "Unknown error", - }, - ); - } - } - - result.success = result.errors.length === 0; - - return result; - } catch (error) { - databaseLogger.error( - "User data re-encryption failed after password reset", - error, - { - operation: "password_reset_reencrypt_failed", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }, - ); - - result.errors.push( - `Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - return result; - } + static getUserDataKey(userId: string): Buffer | null { + return this.userKeys.tryGetUserDEK(userId); } static validateUserAccess(userId: string): Buffer { - const userDataKey = this.getUserDataKey(userId); - if (!userDataKey) { - throw new Error(`User ${userId} data not unlocked`); - } - return userDataKey; + return this.userKeys.getUserDEK(userId); } static encryptRecordForUser>( @@ -446,7 +269,7 @@ class DataCrypto { } static canUserAccessData(userId: string): boolean { - return this.userCrypto.isUserUnlocked(userId); + return this.userKeys.tryGetUserDEK(userId) !== null; } static testUserEncryption(userId: string): boolean { @@ -473,48 +296,6 @@ class DataCrypto { return false; } } - - static async encryptRecordWithSystemKey>( - tableName: string, - record: T, - systemKey: Buffer, - ): Promise> { - const systemEncrypted: Record = {}; - const recordId = record.id || "temp-" + Date.now(); - - if (tableName !== "ssh_credentials") { - return systemEncrypted as Partial; - } - - if (record.password && typeof record.password === "string") { - systemEncrypted.systemPassword = FieldCrypto.encryptField( - record.password as string, - systemKey, - recordId as string, - "password", - ); - } - - if (record.key && typeof record.key === "string") { - systemEncrypted.systemKey = FieldCrypto.encryptField( - record.key as string, - systemKey, - recordId as string, - "key", - ); - } - - if (record.keyPassword && typeof record.keyPassword === "string") { - systemEncrypted.systemKeyPassword = FieldCrypto.encryptField( - record.keyPassword as string, - systemKey, - recordId as string, - "key_password", - ); - } - - return systemEncrypted as Partial; - } } export { DataCrypto }; diff --git a/src/backend/utils/database-layer-preupgrade-backup.ts b/src/backend/utils/database-layer-preupgrade-backup.ts new file mode 100644 index 00000000..e59b5624 --- /dev/null +++ b/src/backend/utils/database-layer-preupgrade-backup.ts @@ -0,0 +1,198 @@ +import fs from "fs"; +import path from "path"; +import { databaseLogger } from "./logger.js"; + +export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER = + ".database-layer-preupgrade-backup.json"; +export const DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX = + "pre-database-layer-refactor"; +export const DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV = + "DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP"; +export const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV = + "DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP"; + +interface PreupgradeBackupOptions { + dataDir?: string; + version?: string; + now?: Date; + env?: NodeJS.ProcessEnv; +} + +interface BackupFileEntry { + source: string; + backup: string; + size: number; +} + +export interface PreupgradeBackupManifest { + reason: "pre-database-layer-refactor"; + createdAt: string; + sourceVersion: string; + dataDir: string; + backupDir: string; + files: BackupFileEntry[]; +} + +export interface PreupgradeBackupResult { + status: "created" | "skipped"; + reason: "backup_created" | "skip_env" | "marker_exists" | "no_database_file"; + backupDir?: string; + markerPath: string; +} + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +function timestampForPath(now: Date): string { + return now.toISOString().replace(/[:.]/g, "-"); +} + +function parseKeepCount(env: NodeJS.ProcessEnv): number { + const raw = env[DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV]; + if (!raw) return 3; + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1) return 3; + return parsed; +} + +function copyIfExists( + source: string, + backupDir: string, +): BackupFileEntry | null { + if (!fs.existsSync(source)) return null; + + const backup = path.join(backupDir, path.basename(source)); + fs.copyFileSync(source, backup); + + return { + source, + backup, + size: fs.statSync(backup).size, + }; +} + +function cleanupOldBackups(backupsRoot: string, keepCount: number): void { + if (!fs.existsSync(backupsRoot)) return; + + const entries = fs + .readdirSync(backupsRoot, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith(`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-`), + ) + .map((entry) => { + const fullPath = path.join(backupsRoot, entry.name); + return { + fullPath, + mtimeMs: fs.statSync(fullPath).mtimeMs, + }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const entry of entries.slice(keepCount)) { + fs.rmSync(entry.fullPath, { recursive: true, force: true }); + } +} + +export function ensureDatabaseLayerPreupgradeBackup( + options: PreupgradeBackupOptions = {}, +): PreupgradeBackupResult { + const env = options.env ?? process.env; + const dataDir = path.resolve(options.dataDir ?? env.DATA_DIR ?? "./db/data"); + const markerPath = path.join( + dataDir, + DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER, + ); + + if ( + TRUE_VALUES.has( + env[DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]?.trim().toLowerCase() ?? + "", + ) + ) { + databaseLogger.warn("Database layer pre-upgrade backup skipped by env", { + operation: "database_layer_preupgrade_backup_skipped", + envKey: DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV, + }); + return { status: "skipped", reason: "skip_env", markerPath }; + } + + if (fs.existsSync(markerPath)) { + return { status: "skipped", reason: "marker_exists", markerPath }; + } + + const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted"); + const encryptedMetadataPath = `${encryptedDbPath}.meta`; + const plaintextDbPath = path.join(dataDir, "db.sqlite"); + const envPath = path.join(dataDir, ".env"); + const databaseFiles = [ + encryptedDbPath, + encryptedMetadataPath, + plaintextDbPath, + ].filter((file) => fs.existsSync(file)); + + if (databaseFiles.length === 0) { + return { status: "skipped", reason: "no_database_file", markerPath }; + } + + const backupsRoot = path.join(dataDir, "backups"); + const backupDir = path.join( + backupsRoot, + `${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-${timestampForPath( + options.now ?? new Date(), + )}`, + ); + + try { + fs.mkdirSync(backupDir, { recursive: true }); + + const files = [...databaseFiles, envPath] + .map((source) => copyIfExists(source, backupDir)) + .filter((entry): entry is BackupFileEntry => entry !== null); + + const manifest: PreupgradeBackupManifest = { + reason: "pre-database-layer-refactor", + createdAt: (options.now ?? new Date()).toISOString(), + sourceVersion: options.version ?? env.VERSION ?? "unknown", + dataDir, + backupDir, + files, + }; + + fs.writeFileSync( + path.join(backupDir, "manifest.json"), + JSON.stringify(manifest, null, 2), + ); + fs.writeFileSync(markerPath, JSON.stringify(manifest, null, 2)); + + cleanupOldBackups(backupsRoot, parseKeepCount(env)); + + databaseLogger.info("Database layer pre-upgrade backup created", { + operation: "database_layer_preupgrade_backup_created", + backupDir, + files: files.map((file) => path.basename(file.backup)), + }); + + return { + status: "created", + reason: "backup_created", + backupDir, + markerPath, + }; + } catch (error) { + databaseLogger.error( + "Failed to create database layer pre-upgrade backup", + error, + { + operation: "database_layer_preupgrade_backup_failed", + dataDir, + backupDir, + }, + ); + throw new Error( + `Failed to create database layer pre-upgrade backup. Set ${DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV}=1 only if you already have a verified external backup.`, + { cause: error }, + ); + } +} diff --git a/src/backend/utils/database-migration.ts b/src/backend/utils/database-migration.ts index 0d35a694..3ddcb86e 100644 --- a/src/backend/utils/database-migration.ts +++ b/src/backend/utils/database-migration.ts @@ -1,8 +1,8 @@ -import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; import { DatabaseFileEncryption } from "./database-file-encryption.js"; +import { LegacySqliteDatabaseCopyStore } from "./legacy-sqlite-database-copy-store.js"; export interface MigrationResult { success: boolean; @@ -124,80 +124,6 @@ export class DatabaseMigration { } } - private async verifyMigration( - originalDb: Database.Database, - memoryDb: Database.Database, - ): Promise { - try { - memoryDb.exec("PRAGMA foreign_keys = OFF"); - - const originalTables = originalDb - .prepare( - ` - SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name - `, - ) - .all() as { name: string }[]; - - const memoryTables = memoryDb - .prepare( - ` - SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name - `, - ) - .all() as { name: string }[]; - - if (originalTables.length !== memoryTables.length) { - databaseLogger.error( - "Table count mismatch during migration verification", - null, - { - operation: "migration_verify_failed", - originalCount: originalTables.length, - memoryCount: memoryTables.length, - }, - ); - return false; - } - - for (const table of originalTables) { - const originalCount = originalDb - .prepare(`SELECT COUNT(*) as count FROM ${table.name}`) - .get() as { count: number }; - const memoryCount = memoryDb - .prepare(`SELECT COUNT(*) as count FROM ${table.name}`) - .get() as { count: number }; - - if (originalCount.count !== memoryCount.count) { - databaseLogger.error( - "Row count mismatch for table during migration verification", - null, - { - operation: "migration_verify_table_failed", - table: table.name, - originalRows: originalCount.count, - memoryRows: memoryCount.count, - }, - ); - return false; - } - } - - memoryDb.exec("PRAGMA foreign_keys = ON"); - - return true; - } catch (error) { - databaseLogger.error("Migration verification failed", error, { - operation: "migration_verify_error", - }); - return false; - } - } - async migrateDatabase(): Promise { const startTime = Date.now(); let backupPath: string | undefined; @@ -207,121 +133,46 @@ export class DatabaseMigration { try { backupPath = this.createBackup(); - const originalDb = new Database(this.unencryptedDbPath, { - readonly: true, + const copyResult = + new LegacySqliteDatabaseCopyStore().copyDatabaseToMemoryBuffer( + this.unencryptedDbPath, + ); + migratedTables = copyResult.migratedTables; + migratedRows = copyResult.migratedRows; + + await DatabaseFileEncryption.encryptDatabaseFromBuffer( + copyResult.buffer, + this.encryptedDbPath, + ); + + if ( + !DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath) + ) { + throw new Error("Encrypted database file verification failed"); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`; + + fs.renameSync(this.unencryptedDbPath, migratedPath); + + databaseLogger.success("Database migration completed successfully", { + operation: "migration_complete", + migratedTables, + migratedRows, + duration: Date.now() - startTime, + backupPath, + migratedPath, + encryptedDbPath: this.encryptedDbPath, }); - const memoryDb = new Database(":memory:"); - - try { - const tables = originalDb - .prepare( - ` - SELECT name, sql FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - `, - ) - .all() as { name: string; sql: string }[]; - - for (const table of tables) { - memoryDb.exec(table.sql); - migratedTables++; - } - - memoryDb.exec("PRAGMA foreign_keys = OFF"); - - for (const table of tables) { - const rows = originalDb - .prepare(`SELECT * FROM ${table.name}`) - .all() as Record[]; - - if (rows.length > 0) { - const columns = Object.keys(rows[0]); - const placeholders = columns.map(() => "?").join(", "); - const insertStmt = memoryDb.prepare( - `INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`, - ); - - const insertTransaction = memoryDb.transaction( - (dataRows: Record[]) => { - for (const row of dataRows) { - const values = columns.map((col) => row[col]); - insertStmt.run(values); - } - }, - ); - - insertTransaction(rows); - migratedRows += rows.length; - } - } - - memoryDb.exec("PRAGMA foreign_keys = ON"); - - const fkCheckResult = memoryDb - .prepare("PRAGMA foreign_key_check") - .all(); - if (fkCheckResult.length > 0) { - databaseLogger.error( - "Foreign key constraints violations detected after migration", - null, - { - operation: "migration_fk_check_failed", - violations: fkCheckResult, - }, - ); - throw new Error( - `Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`, - ); - } - - const verificationPassed = await this.verifyMigration( - originalDb, - memoryDb, - ); - if (!verificationPassed) { - throw new Error("Migration integrity verification failed"); - } - - const buffer = memoryDb.serialize(); - - await DatabaseFileEncryption.encryptDatabaseFromBuffer( - buffer, - this.encryptedDbPath, - ); - - if ( - !DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath) - ) { - throw new Error("Encrypted database file verification failed"); - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`; - - fs.renameSync(this.unencryptedDbPath, migratedPath); - - databaseLogger.success("Database migration completed successfully", { - operation: "migration_complete", - migratedTables, - migratedRows, - duration: Date.now() - startTime, - backupPath, - migratedPath, - encryptedDbPath: this.encryptedDbPath, - }); - - return { - success: true, - migratedTables, - migratedRows, - backupPath, - duration: Date.now() - startTime, - }; - } finally { - originalDb.close(); - memoryDb.close(); - } + return { + success: true, + migratedTables, + migratedRows, + backupPath, + duration: Date.now() - startTime, + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index e831ea9f..0c3d0a33 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -1,13 +1,6 @@ import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; - -interface DatabaseInstance { - prepare: (sql: string) => { - all: (param?: unknown) => unknown[]; - get: (param?: unknown) => unknown; - run: (...params: unknown[]) => unknown; - }; -} +import type { UserEncryptionMigrationStore } from "./user-encryption-migration-store.js"; export class LazyFieldEncryption { private static readonly LEGACY_FIELD_NAME_MAP: Record = { @@ -367,7 +360,7 @@ export class LazyFieldEncryption { static async checkUserNeedsMigration( userId: string, userKEK: Buffer, - db: DatabaseInstance, + store: UserEncryptionMigrationStore, ): Promise<{ needsMigration: boolean; plaintextFields: Array<{ @@ -384,11 +377,7 @@ export class LazyFieldEncryption { let needsMigration = false; try { - const sshHosts = db - .prepare("SELECT * FROM ssh_data WHERE user_id = ?") - .all(userId) as Array< - Record & { id: string | number } - >; + const sshHosts = store.listHostRecords(userId); for (const host of sshHosts) { const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data"); const hostPlaintextFields: string[] = []; @@ -418,11 +407,7 @@ export class LazyFieldEncryption { } } - const sshCredentials = db - .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") - .all(userId) as Array< - Record & { id: string | number } - >; + const sshCredentials = store.listCredentialRecords(userId); for (const credential of sshCredentials) { const sensitiveFields = this.getSensitiveFieldsForTable("ssh_credentials"); @@ -453,16 +438,18 @@ export class LazyFieldEncryption { } } - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId); + const user = store.getUserRecord(userId); if (user) { const sensitiveFields = this.getSensitiveFieldsForTable("users"); const userPlaintextFields: string[] = []; for (const field of sensitiveFields) { const column = this.propertyToColumn(field); + const value = user[column]; if ( - user[column] && - this.fieldNeedsMigration(user[column], userKEK, userId, field) + typeof value === "string" && + value && + this.fieldNeedsMigration(value, userKEK, userId, field) ) { userPlaintextFields.push(field); needsMigration = true; diff --git a/src/backend/utils/legacy-sqlite-database-copy-store.ts b/src/backend/utils/legacy-sqlite-database-copy-store.ts new file mode 100644 index 00000000..b94c9284 --- /dev/null +++ b/src/backend/utils/legacy-sqlite-database-copy-store.ts @@ -0,0 +1,169 @@ +import Database from "better-sqlite3"; +import { databaseLogger } from "./logger.js"; + +interface LegacyTableDefinition { + name: string; + sql: string; +} + +export interface LegacyDatabaseCopyResult { + buffer: Buffer; + migratedTables: number; + migratedRows: number; +} + +export class LegacySqliteDatabaseCopyStore { + copyDatabaseToMemoryBuffer(sourcePath: string): LegacyDatabaseCopyResult { + const originalDb = new Database(sourcePath, { readonly: true }); + const memoryDb = new Database(":memory:"); + + try { + const tables = this.listTableDefinitions(originalDb); + let migratedTables = 0; + let migratedRows = 0; + + for (const table of tables) { + memoryDb.exec(table.sql); + migratedTables++; + } + + memoryDb.exec("PRAGMA foreign_keys = OFF"); + + for (const table of tables) { + migratedRows += this.copyTableRows(originalDb, memoryDb, table.name); + } + + memoryDb.exec("PRAGMA foreign_keys = ON"); + this.assertNoForeignKeyViolations(memoryDb); + this.assertRowCountsMatch(originalDb, memoryDb); + + return { + buffer: memoryDb.serialize(), + migratedTables, + migratedRows, + }; + } finally { + originalDb.close(); + memoryDb.close(); + } + } + + private listTableDefinitions(db: Database.Database): LegacyTableDefinition[] { + return db + .prepare( + ` + SELECT name, sql FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `, + ) + .all() as LegacyTableDefinition[]; + } + + private listTableNames(db: Database.Database): string[] { + return db + .prepare( + ` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `, + ) + .all() + .map((table) => (table as { name: string }).name); + } + + private copyTableRows( + originalDb: Database.Database, + memoryDb: Database.Database, + tableName: string, + ): number { + const rows = originalDb + .prepare(`SELECT * FROM ${tableName}`) + .all() as Record[]; + + if (rows.length === 0) { + return 0; + } + + const columns = Object.keys(rows[0]); + const placeholders = columns.map(() => "?").join(", "); + const insertStmt = memoryDb.prepare( + `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`, + ); + + const insertTransaction = memoryDb.transaction( + (dataRows: Record[]) => { + for (const row of dataRows) { + insertStmt.run(columns.map((column) => row[column])); + } + }, + ); + + insertTransaction(rows); + return rows.length; + } + + private assertNoForeignKeyViolations(memoryDb: Database.Database): void { + const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all(); + if (fkCheckResult.length === 0) { + return; + } + + databaseLogger.error( + "Foreign key constraints violations detected after migration", + null, + { + operation: "migration_fk_check_failed", + violations: fkCheckResult, + }, + ); + throw new Error( + `Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`, + ); + } + + private assertRowCountsMatch( + originalDb: Database.Database, + memoryDb: Database.Database, + ): void { + const originalTables = this.listTableNames(originalDb); + const memoryTables = this.listTableNames(memoryDb); + + if (originalTables.length !== memoryTables.length) { + databaseLogger.error( + "Table count mismatch during migration verification", + null, + { + operation: "migration_verify_failed", + originalCount: originalTables.length, + memoryCount: memoryTables.length, + }, + ); + throw new Error("Migration integrity verification failed"); + } + + for (const tableName of originalTables) { + const originalCount = originalDb + .prepare(`SELECT COUNT(*) as count FROM ${tableName}`) + .get() as { count: number }; + const memoryCount = memoryDb + .prepare(`SELECT COUNT(*) as count FROM ${tableName}`) + .get() as { count: number }; + + if (originalCount.count !== memoryCount.count) { + databaseLogger.error( + "Row count mismatch for table during migration verification", + null, + { + operation: "migration_verify_table_failed", + table: tableName, + originalRows: originalCount.count, + memoryRows: memoryCount.count, + }, + ); + throw new Error("Migration integrity verification failed"); + } + } + } +} diff --git a/src/backend/utils/notification-sender.ts b/src/backend/utils/notification-sender.ts index eb83cf70..768bef2d 100644 --- a/src/backend/utils/notification-sender.ts +++ b/src/backend/utils/notification-sender.ts @@ -1,4 +1,5 @@ import { statsLogger } from "./logger.js"; +import { safeOutboundFetch } from "./safe-outbound-fetch.js"; export interface AlertPayload { hostName: string; @@ -36,7 +37,7 @@ async function fetchWithRetry( options: RequestInit, ): Promise { const attempt = async () => { - const res = await fetch(url, options); + const res = await safeOutboundFetch(url, options); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } diff --git a/src/backend/utils/oidc-desktop-callback.ts b/src/backend/utils/oidc-desktop-callback.ts new file mode 100644 index 00000000..898431fe --- /dev/null +++ b/src/backend/utils/oidc-desktop-callback.ts @@ -0,0 +1,25 @@ +const CALLBACK_PATH = "/oidc-callback"; + +export function getDesktopOidcCallbackUrl(value: unknown): string | null { + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + + return `http://localhost:${port}${CALLBACK_PATH}`; +} + +export function isOidcTokenCallback(value: string): boolean { + if (value.startsWith("termix-mobile:")) return true; + + try { + const url = new URL(value); + return ( + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1") && + url.pathname === CALLBACK_PATH + ); + } catch { + return false; + } +} diff --git a/src/backend/utils/permission-catalog.ts b/src/backend/utils/permission-catalog.ts new file mode 100644 index 00000000..6e78af0d --- /dev/null +++ b/src/backend/utils/permission-catalog.ts @@ -0,0 +1,60 @@ +// Single source of truth for role permission strings. The admin role editor +// renders this catalog and PUT /rbac/roles/:id validates against it; +// PermissionManager.hasPermission resolves wildcards ("*", ".*"). +export interface PermissionCatalogEntry { + group: string; + permissions: string[]; +} + +export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [ + { + group: "hosts", + permissions: [ + "hosts.view", + "hosts.create", + "hosts.edit", + "hosts.delete", + "hosts.share", + ], + }, + { + group: "snippets", + permissions: [ + "snippets.view", + "snippets.create", + "snippets.edit", + "snippets.delete", + "snippets.share", + ], + }, + { + group: "credentials", + permissions: [ + "credentials.view", + "credentials.create", + "credentials.edit", + "credentials.delete", + ], + }, + { + group: "admin", + permissions: [ + "admin.users.view", + "admin.users.manage", + "admin.roles.manage", + "admin.settings.manage", + "admin.sessions.manage", + ], + }, +]; + +const VALID_PERMISSIONS = new Set( + PERMISSION_CATALOG.flatMap((entry) => [ + ...entry.permissions, + `${entry.group}.*`, + ]).concat("*"), +); + +export function isValidPermission(permission: string): boolean { + return VALID_PERMISSIONS.has(permission); +} diff --git a/src/backend/utils/permission-manager.test.ts b/src/backend/utils/permission-manager.test.ts deleted file mode 100644 index 37a87aa8..00000000 --- a/src/backend/utils/permission-manager.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -// permission-manager imports the side-effectful DB barrel and the logger at the -// top level. Stub both so importing the module does not spin up the real -// database / encryption stack. We then drive hasPermission via a spied -// getUserPermissions so we test the wildcard-matching logic in isolation. -vi.mock("../database/db/index.js", () => ({ db: {} })); -vi.mock("./logger.js", () => ({ - databaseLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -const { PermissionManager } = await import("./permission-manager.js"); - -type PermissionManagerInstance = ReturnType< - typeof PermissionManager.getInstance ->; - -describe("PermissionManager.hasPermission wildcard matching", () => { - let manager: PermissionManagerInstance; - - function withPermissions(permissions: string[]) { - vi.spyOn(manager, "getUserPermissions").mockResolvedValue(permissions); - } - - beforeEach(() => { - manager = PermissionManager.getInstance(); - vi.restoreAllMocks(); - }); - - it("grants everything for the global wildcard '*'", async () => { - withPermissions(["*"]); - expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); - expect(await manager.hasPermission("u1", "anything.at.all")).toBe(true); - }); - - it("grants an exact permission match", async () => { - withPermissions(["hosts.read", "hosts.write"]); - expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); - }); - - it("grants via a prefix wildcard", async () => { - withPermissions(["hosts.*"]); - expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); - expect(await manager.hasPermission("u1", "hosts.write")).toBe(true); - }); - - it("grants via a deep prefix wildcard", async () => { - withPermissions(["admin.users.*"]); - expect(await manager.hasPermission("u1", "admin.users.delete")).toBe(true); - }); - - it("denies when no exact or wildcard permission matches", async () => { - withPermissions(["hosts.read"]); - expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); - expect(await manager.hasPermission("u1", "credentials.read")).toBe(false); - }); - - it("denies when the user has no permissions", async () => { - withPermissions([]); - expect(await manager.hasPermission("u1", "hosts.read")).toBe(false); - }); - - it("does not let a narrower wildcard grant a sibling branch", async () => { - withPermissions(["hosts.read.*"]); - expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); - }); -}); diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index bd214958..a3120726 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -1,13 +1,10 @@ import type { Request, Response, NextFunction } from "express"; -import { db } from "../database/db/index.js"; import { - hostAccess, - roles, - userRoles, - hosts, - users, -} from "../database/db/schema.js"; -import { eq, and, or, isNull, gte, sql } from "drizzle-orm"; + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentUserRepository, + createCurrentHostResolutionRepository, +} from "../database/repositories/factory.js"; import { databaseLogger } from "./logger.js"; interface AuthenticatedRequest extends Request { @@ -15,11 +12,33 @@ interface AuthenticatedRequest extends Request { dataKey?: Buffer; } +const SHARE_PERMISSION_LEVELS = ["connect", "view", "edit", "manage"] as const; + +type SharePermissionLevel = (typeof SHARE_PERMISSION_LEVELS)[number]; + +type HostAction = SharePermissionLevel | "delete"; + +const LEVEL_RANK: Record = { + connect: 1, + view: 2, + edit: 3, + manage: 4, +}; + +function normalizeSharePermissionLevel( + level: string | null | undefined, +): SharePermissionLevel { + return SHARE_PERMISSION_LEVELS.includes(level as SharePermissionLevel) + ? (level as SharePermissionLevel) + : "connect"; +} + interface HostAccessInfo { hasAccess: boolean; isOwner: boolean; isShared: boolean; - permissionLevel?: "view"; + isAdminBypass?: boolean; + permissionLevel?: SharePermissionLevel; expiresAt?: string | null; } @@ -65,15 +84,7 @@ class PermissionManager { private async cleanupExpiredAccess(): Promise { try { - const now = new Date().toISOString(); - await db - .delete(hostAccess) - .where( - and( - sql`${hostAccess.expiresAt} IS NOT NULL`, - sql`${hostAccess.expiresAt} <= ${now}`, - ), - ); + await createCurrentRbacAccessRepository().deleteExpiredHostAccess(); } catch (error) { databaseLogger.error("Failed to cleanup expired host access", error, { operation: "host_access_cleanup_failed", @@ -96,13 +107,8 @@ class PermissionManager { } try { - const userRoleRecords = await db - .select({ - permissions: roles.permissions, - }) - .from(userRoles) - .innerJoin(roles, eq(userRoles.roleId, roles.id)) - .where(eq(userRoles.userId, userId)); + const userRoleRecords = + await createCurrentRoleRepository().listUserRolePermissions(userId); const allPermissions = new Set(); for (const record of userRoleRecords) { @@ -162,16 +168,12 @@ class PermissionManager { async canAccessHost( userId: string, hostId: number, - action: "read" | "write" | "execute" | "delete" | "share" = "read", + action: HostAction = "connect", ): Promise { try { - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); + const hostResolutionRepository = createCurrentHostResolutionRepository(); - if (host.length > 0) { + if (await hostResolutionRepository.isHostOwnedByUser(hostId, userId)) { return { hasAccess: true, isOwner: true, @@ -179,43 +181,20 @@ class PermissionManager { }; } - const userRoleIds = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); - const now = new Date().toISOString(); - const sharedAccess = await db - .select() - .from(hostAccess) - .where( - and( - eq(hostAccess.hostId, hostId), - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? sql`${hostAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - ) - .limit(1); + const access = + await createCurrentRbacAccessRepository().findActiveHostAccess( + hostId, + userId, + roleIds, + ); - if (sharedAccess.length > 0) { - const access = sharedAccess[0]; + if (access) { + const ownerId = await hostResolutionRepository.findHostOwnerId(hostId); - const hostOwnerCheck = await db - .select({ ownerId: hosts.userId }) - .from(hosts) - .where(eq(hosts.id, hostId)) - .limit(1); - - if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) { + if (ownerId === userId) { return { hasAccess: true, isOwner: true, @@ -223,39 +202,52 @@ class PermissionManager { }; } - if (action === "write" || action === "delete") { + const grantedLevel = normalizeSharePermissionLevel( + access.permissionLevel, + ); + + if ( + action === "delete" || + LEVEL_RANK[grantedLevel] < LEVEL_RANK[action] + ) { + if (await this.isAdmin(userId)) { + return this.adminBypassAccess(); + } return { hasAccess: false, isOwner: false, isShared: true, - permissionLevel: access.permissionLevel as "view", + permissionLevel: grantedLevel, expiresAt: access.expiresAt, }; } - try { - await db - .update(hostAccess) - .set({ - lastAccessedAt: now, - }) - .where(eq(hostAccess.id, access.id)); - } catch (error) { - databaseLogger.warn("Failed to update host access timestamp", { - operation: "update_host_access_timestamp", - error, - }); + if (action === "connect") { + try { + await createCurrentRbacAccessRepository().touchHostAccess( + access.id, + ); + } catch (error) { + databaseLogger.warn("Failed to update host access timestamp", { + operation: "update_host_access_timestamp", + error, + }); + } } return { hasAccess: true, isOwner: false, isShared: true, - permissionLevel: access.permissionLevel as "view", + permissionLevel: grantedLevel, expiresAt: access.expiresAt, }; } + if (await this.isAdmin(userId)) { + return this.adminBypassAccess(); + } + return { hasAccess: false, isOwner: false, @@ -276,30 +268,30 @@ class PermissionManager { } } + // Admins get owner-equivalent access to every host; each connect is + // audit-logged in the host resolver. + private adminBypassAccess(): HostAccessInfo { + return { + hasAccess: true, + isOwner: false, + isShared: false, + isAdminBypass: true, + permissionLevel: "manage", + }; + } + async isAdmin(userId: string): Promise { try { - const user = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); + const user = await createCurrentUserRepository().findById(userId); - if (user.length > 0 && user[0].isAdmin) { + if (user?.isAdmin) { return true; } - const adminRoles = await db - .select({ roleName: roles.name }) - .from(userRoles) - .innerJoin(roles, eq(userRoles.roleId, roles.id)) - .where( - and( - eq(userRoles.userId, userId), - or(eq(roles.name, "admin"), eq(roles.name, "super_admin")), - ), - ); - - return adminRoles.length > 0; + return createCurrentRoleRepository().userHasAnyRoleName(userId, [ + "admin", + "super_admin", + ]); } catch (error) { databaseLogger.error("Failed to check admin status", error, { operation: "is_admin", @@ -343,7 +335,7 @@ class PermissionManager { requireHostAccess( hostIdParam: string = "id", - action: "read" | "write" | "execute" | "delete" | "share" = "read", + action: HostAction = "connect", ) { return async ( req: AuthenticatedRequest, @@ -418,5 +410,11 @@ class PermissionManager { } } -export { PermissionManager }; -export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult }; +export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK }; +export type { + AuthenticatedRequest, + HostAccessInfo, + PermissionCheckResult, + SharePermissionLevel, + HostAction, +}; diff --git a/src/backend/utils/request-origin.ts b/src/backend/utils/request-origin.ts index 1f91d1cf..7ecf54eb 100644 --- a/src/backend/utils/request-origin.ts +++ b/src/backend/utils/request-origin.ts @@ -7,6 +7,44 @@ function firstHeaderValue(value: string | string[] | undefined): string { return raw.split(",")[0].trim(); } +function normalizePort(value: string | string[] | undefined): string { + const raw = firstHeaderValue(value); + if (!/^\d+$/.test(raw)) return ""; + + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) return ""; + + return String(port); +} + +function splitHostHeader(value: string | string[] | undefined): { + host: string; + port: string; +} { + const raw = firstHeaderValue(value) || "localhost"; + + if (raw.startsWith("[")) { + const match = raw.match(/^(\[[^\]]+\])(?::(.+))?$/); + return { + host: match?.[1] || raw, + port: normalizePort(match?.[2]), + }; + } + + const parts = raw.split(":"); + if (parts.length === 2) { + return { + host: parts[0] || "localhost", + port: normalizePort(parts[1]), + }; + } + + return { + host: raw, + port: "", + }; +} + export function normalizeBasePath(value: unknown): string { if (typeof value !== "string") return ""; let basePath = value.split(",")[0].trim(); @@ -45,38 +83,23 @@ export function getRequestOrigin(req: Request | IncomingMessage): string { : "http"; } - const portHeader = req.headers["x-forwarded-port"]; - let port: string | undefined = - typeof portHeader === "string" - ? portHeader.split(",")[0].trim() - : undefined; + let port = normalizePort(req.headers["x-forwarded-port"]); + const { host, port: hostPort } = splitHostHeader( + req.headers["x-forwarded-host"] || req.headers.host, + ); + port ||= hostPort; - const hostHeaderRaw = - req.headers["x-forwarded-host"] || req.headers.host || "localhost"; - const hostHeader = - typeof hostHeaderRaw === "string" - ? hostHeaderRaw.split(",")[0].trim() - : String(hostHeaderRaw); - - if (!port && hostHeader.includes(":")) { - const parts = hostHeader.split(":"); - if (parts.length === 2 && !parts[0].includes("[")) { - port = parts[1]; - } - } - - const hostWithoutPort = hostHeader.split(":")[0]; if (port) { const isDefaultPort = (protocol === "http" && port === "80") || (protocol === "https" && port === "443"); return isDefaultPort - ? `${protocol}://${hostWithoutPort}` - : `${protocol}://${hostWithoutPort}:${port}`; + ? `${protocol}://${host}` + : `${protocol}://${host}:${port}`; } - return `${protocol}://${hostWithoutPort}`; + return `${protocol}://${host}`; } export function getRequestOriginWithForceHTTPS( diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts new file mode 100644 index 00000000..1448be0e --- /dev/null +++ b/src/backend/utils/safe-outbound-fetch.ts @@ -0,0 +1,94 @@ +import { lookup } from "dns"; +import { BlockList, isIP } from "net"; +import { Agent } from "undici"; + +const blockedAddresses = new BlockList(); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + blockedAddresses.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["::ffff:0:0", 96], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], +] as const) { + blockedAddresses.addSubnet(network, prefix, "ipv6"); +} + +function isBlockedAddress(address: string): boolean { + const family = isIP(address); + return ( + family === 0 || + blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6") + ); +} + +export async function safeOutboundFetch( + rawUrl: string, + options: RequestInit, +): Promise { + const url = new URL(rawUrl); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new Error("Invalid outbound URL"); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(hostname) && isBlockedAddress(hostname)) { + throw new Error("Private destinations are not allowed"); + } + + const dispatcher = new Agent({ + connect: { + lookup(host, lookupOptions, callback) { + lookup( + host, + { ...lookupOptions, all: true, verbatim: true }, + (error, addresses) => { + if (error) return callback(error, "", 0); + if ( + !addresses.length || + addresses.some(({ address }) => isBlockedAddress(address)) + ) { + return callback( + new Error("Private destinations are not allowed"), + "", + 0, + ); + } + const selected = addresses[0]; + callback(null, selected.address, selected.family); + }, + ); + }, + }, + }); + + try { + return await fetch(url, { + ...options, + redirect: "error", + dispatcher, + } as RequestInit & { dispatcher: Agent }); + } finally { + await dispatcher.close(); + } +} diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts deleted file mode 100644 index a000ecda..00000000 --- a/src/backend/utils/shared-credential-manager.ts +++ /dev/null @@ -1,768 +0,0 @@ -import { db } from "../database/db/index.js"; -import { - sharedCredentials, - sshCredentials, - hostAccess, - userRoles, - hosts, -} from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { DataCrypto } from "./data-crypto.js"; -import { FieldCrypto } from "./field-crypto.js"; -import { databaseLogger } from "./logger.js"; - -interface CredentialData { - username: string; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; -} - -class SharedCredentialManager { - private static instance: SharedCredentialManager; - - private constructor() {} - - static getInstance(): SharedCredentialManager { - if (!this.instance) { - this.instance = new SharedCredentialManager(); - } - return this.instance; - } - - async createSharedCredentialForUser( - hostAccessId: number, - originalCredentialId: number, - targetUserId: string, - ownerId: string, - ): Promise { - try { - const existing = await db - .select({ id: sharedCredentials.id }) - .from(sharedCredentials) - .where( - and( - eq(sharedCredentials.hostAccessId, hostAccessId), - eq(sharedCredentials.targetUserId, targetUserId), - ), - ) - .limit(1); - - if (existing.length > 0) return; - - const ownerDEK = DataCrypto.getUserDataKey(ownerId); - - if (ownerDEK) { - const targetDEK = DataCrypto.getUserDataKey(targetUserId); - if (!targetDEK) { - await this.createPendingSharedCredential( - hostAccessId, - originalCredentialId, - targetUserId, - ); - return; - } - - const credentialData = await this.getDecryptedCredential( - originalCredentialId, - ownerId, - ownerDEK, - ); - - const encryptedForTarget = this.encryptCredentialForUser( - credentialData, - targetUserId, - targetDEK, - hostAccessId, - ); - - await db.insert(sharedCredentials).values({ - hostAccessId, - originalCredentialId, - targetUserId, - ...encryptedForTarget, - needsReEncryption: false, - }); - } else { - const targetDEK = DataCrypto.getUserDataKey(targetUserId); - if (!targetDEK) { - await this.createPendingSharedCredential( - hostAccessId, - originalCredentialId, - targetUserId, - ); - return; - } - - const credentialData = - await this.getDecryptedCredentialViaSystemKey(originalCredentialId); - - const encryptedForTarget = this.encryptCredentialForUser( - credentialData, - targetUserId, - targetDEK, - hostAccessId, - ); - - await db.insert(sharedCredentials).values({ - hostAccessId, - originalCredentialId, - targetUserId, - ...encryptedForTarget, - needsReEncryption: false, - }); - } - } catch (error) { - databaseLogger.error("Failed to create shared credential", error, { - operation: "create_shared_credential", - hostAccessId, - targetUserId, - }); - throw error; - } - } - - async createSharedCredentialsForRole( - hostAccessId: number, - originalCredentialId: number, - roleId: number, - ownerId: string, - ): Promise { - try { - const roleUsers = await db - .select({ userId: userRoles.userId }) - .from(userRoles) - .where(eq(userRoles.roleId, roleId)); - - for (const { userId } of roleUsers) { - try { - await this.createSharedCredentialForUser( - hostAccessId, - originalCredentialId, - userId, - ownerId, - ); - } catch (error) { - databaseLogger.error( - "Failed to create shared credential for role member", - error, - { - operation: "create_shared_credentials_role", - hostAccessId, - roleId, - userId, - }, - ); - } - } - } catch (error) { - databaseLogger.error( - "Failed to create shared credentials for role", - error, - { - operation: "create_shared_credentials_role", - hostAccessId, - roleId, - }, - ); - throw error; - } - } - - async createSharedCredentialsForRoleMember( - roleId: number, - targetUserId: string, - ): Promise { - try { - const hostsSharedWithRole = await db - .select() - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .where(eq(hostAccess.roleId, roleId)); - - for (const { host_access, ssh_data } of hostsSharedWithRole) { - const activeCredentialId = - ssh_data.credentialId ?? - ssh_data.rdpCredentialId ?? - ssh_data.vncCredentialId ?? - ssh_data.telnetCredentialId; - - if (!activeCredentialId) continue; - - try { - await this.createSharedCredentialForUser( - host_access.id, - activeCredentialId, - targetUserId, - ssh_data.userId, - ); - } catch (error) { - databaseLogger.error( - "Failed to create shared credential for role member", - error, - { - operation: "create_shared_credentials_role_member", - roleId, - targetUserId, - hostId: ssh_data.id, - }, - ); - } - } - } catch (error) { - databaseLogger.error( - "Failed to create shared credentials for role member", - error, - { - operation: "create_shared_credentials_role_member", - roleId, - targetUserId, - }, - ); - throw error; - } - } - - async createSharedCredentialsForUserRoles(userId: string): Promise { - try { - const roleRows = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - - for (const { roleId } of roleRows) { - await this.createSharedCredentialsForRoleMember(roleId, userId); - } - } catch (error) { - databaseLogger.error( - "Failed to create shared credentials for user roles", - error, - { - operation: "create_shared_credentials_user_roles", - userId, - }, - ); - throw error; - } - } - - async getSharedCredentialForUser( - hostId: number, - userId: string, - ): Promise { - try { - const userDEK = DataCrypto.getUserDataKey(userId); - if (!userDEK) { - throw new Error(`User ${userId} data not unlocked`); - } - - const sharedCred = await db - .select() - .from(sharedCredentials) - .innerJoin( - hostAccess, - eq(sharedCredentials.hostAccessId, hostAccess.id), - ) - .where( - and( - eq(hostAccess.hostId, hostId), - eq(sharedCredentials.targetUserId, userId), - ), - ) - .limit(1); - - if (sharedCred.length === 0) { - return null; - } - - const cred = sharedCred[0].shared_credentials; - - if (cred.needsReEncryption) { - await this.reEncryptSharedCredential(cred.id, userId); - - const refreshed = await db - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.id, cred.id)) - .limit(1); - - if (refreshed.length === 0 || refreshed[0].needsReEncryption) { - databaseLogger.warn( - "Shared credential needs re-encryption but cannot be accessed yet", - { - operation: "get_shared_credential_pending", - hostId, - userId, - }, - ); - return null; - } - - return this.decryptSharedCredential(refreshed[0], userDEK); - } - - return this.decryptSharedCredential(cred, userDEK); - } catch (error) { - databaseLogger.error("Failed to get shared credential", error, { - operation: "get_shared_credential", - hostId, - userId, - }); - throw error; - } - } - - async updateSharedCredentialsForOriginal( - credentialId: number, - ownerId: string, - ): Promise { - try { - const sharedCreds = await db - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); - - const ownerDEK = DataCrypto.getUserDataKey(ownerId); - let credentialData: CredentialData; - - if (ownerDEK) { - credentialData = await this.getDecryptedCredential( - credentialId, - ownerId, - ownerDEK, - ); - } else { - try { - credentialData = - await this.getDecryptedCredentialViaSystemKey(credentialId); - } catch (error) { - databaseLogger.warn( - "Cannot update shared credentials: owner offline and credential not migrated", - { - operation: "update_shared_credentials_failed", - credentialId, - ownerId, - error: error instanceof Error ? error.message : "Unknown error", - }, - ); - await db - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); - return; - } - } - - for (const sharedCred of sharedCreds) { - const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId); - - if (!targetDEK) { - await db - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.id, sharedCred.id)); - continue; - } - - const encryptedForTarget = this.encryptCredentialForUser( - credentialData, - sharedCred.targetUserId, - targetDEK, - sharedCred.hostAccessId, - ); - - await db - .update(sharedCredentials) - .set({ - ...encryptedForTarget, - needsReEncryption: false, - updatedAt: new Date().toISOString(), - }) - .where(eq(sharedCredentials.id, sharedCred.id)); - } - } catch (error) { - databaseLogger.error("Failed to update shared credentials", error, { - operation: "update_shared_credentials", - credentialId, - }); - } - } - - async deleteSharedCredentialsForOriginal( - credentialId: number, - ): Promise { - try { - await db - .delete(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); - } catch (error) { - databaseLogger.error("Failed to delete shared credentials", error, { - operation: "delete_shared_credentials", - credentialId, - }); - } - } - - async reEncryptPendingCredentialsForUser(userId: string): Promise { - try { - const userDEK = DataCrypto.getUserDataKey(userId); - if (!userDEK) { - return; - } - - const pendingCreds = await db - .select() - .from(sharedCredentials) - .where( - and( - eq(sharedCredentials.targetUserId, userId), - eq(sharedCredentials.needsReEncryption, true), - ), - ); - - for (const cred of pendingCreds) { - await this.reEncryptSharedCredential(cred.id, userId); - } - } catch (error) { - databaseLogger.error("Failed to re-encrypt pending credentials", error, { - operation: "reencrypt_pending_credentials", - userId, - }); - } - } - - private async getDecryptedCredential( - credentialId: number, - ownerId: string, - ownerDEK: Buffer, - ): Promise { - const creds = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, ownerId), - ), - ) - .limit(1); - - if (creds.length === 0) { - throw new Error(`Credential ${credentialId} not found`); - } - - const cred = creds[0]; - - return { - username: cred.username, - authType: cred.authType, - password: cred.password - ? this.decryptField(cred.password, ownerDEK, credentialId, "password") - : undefined, - key: cred.key - ? this.decryptField(cred.key, ownerDEK, credentialId, "key") - : undefined, - keyPassword: cred.keyPassword - ? this.decryptField( - cred.keyPassword, - ownerDEK, - credentialId, - "keyPassword", - ) - : undefined, - keyType: cred.keyType, - }; - } - - private async getDecryptedCredentialViaSystemKey( - credentialId: number, - ): Promise { - const creds = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, credentialId)) - .limit(1); - - if (creds.length === 0) { - throw new Error(`Credential ${credentialId} not found`); - } - - const cred = creds[0]; - - if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) { - throw new Error( - "Credential not yet migrated for offline sharing. " + - "Please ask credential owner to log in to enable sharing.", - ); - } - - const { SystemCrypto } = await import("./system-crypto.js"); - const systemCrypto = SystemCrypto.getInstance(); - const CSKEK = await systemCrypto.getCredentialSharingKey(); - - return { - username: cred.username, - authType: cred.authType, - password: cred.systemPassword - ? this.decryptField( - cred.systemPassword, - CSKEK, - credentialId, - "password", - ) - : undefined, - key: cred.systemKey - ? this.decryptField(cred.systemKey, CSKEK, credentialId, "key") - : undefined, - keyPassword: cred.systemKeyPassword - ? this.decryptField( - cred.systemKeyPassword, - CSKEK, - credentialId, - "key_password", - ) - : undefined, - keyType: cred.keyType, - }; - } - - private encryptCredentialForUser( - credentialData: CredentialData, - targetUserId: string, - targetDEK: Buffer, - hostAccessId: number, - ): { - encryptedUsername: string; - encryptedAuthType: string; - encryptedPassword: string | null; - encryptedKey: string | null; - encryptedKeyPassword: string | null; - encryptedKeyType: string | null; - } { - const recordId = `shared-${hostAccessId}-${targetUserId}`; - - return { - encryptedUsername: FieldCrypto.encryptField( - credentialData.username, - targetDEK, - recordId, - "username", - ), - encryptedAuthType: credentialData.authType, - encryptedPassword: credentialData.password - ? FieldCrypto.encryptField( - credentialData.password, - targetDEK, - recordId, - "password", - ) - : null, - encryptedKey: credentialData.key - ? FieldCrypto.encryptField( - credentialData.key, - targetDEK, - recordId, - "key", - ) - : null, - encryptedKeyPassword: credentialData.keyPassword - ? FieldCrypto.encryptField( - credentialData.keyPassword, - targetDEK, - recordId, - "key_password", - ) - : null, - encryptedKeyType: credentialData.keyType || null, - }; - } - - private decryptSharedCredential( - sharedCred: typeof sharedCredentials.$inferSelect, - userDEK: Buffer, - ): CredentialData { - const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`; - - return { - username: FieldCrypto.decryptField( - sharedCred.encryptedUsername, - userDEK, - recordId, - "username", - ), - authType: sharedCred.encryptedAuthType, - password: sharedCred.encryptedPassword - ? FieldCrypto.decryptField( - sharedCred.encryptedPassword, - userDEK, - recordId, - "password", - ) - : undefined, - key: sharedCred.encryptedKey - ? FieldCrypto.decryptField( - sharedCred.encryptedKey, - userDEK, - recordId, - "key", - ) - : undefined, - keyPassword: sharedCred.encryptedKeyPassword - ? FieldCrypto.decryptField( - sharedCred.encryptedKeyPassword, - userDEK, - recordId, - "key_password", - ) - : undefined, - keyType: sharedCred.encryptedKeyType || undefined, - }; - } - - private decryptField( - encryptedValue: string, - dek: Buffer, - recordId: number | string, - fieldName: string, - ): string { - try { - return FieldCrypto.decryptField( - encryptedValue, - dek, - recordId.toString(), - fieldName, - ); - } catch { - databaseLogger.warn("Field decryption failed, returning as-is", { - operation: "decrypt_field", - fieldName, - recordId, - }); - return encryptedValue; - } - } - - private async createPendingSharedCredential( - hostAccessId: number, - originalCredentialId: number, - targetUserId: string, - ): Promise { - await db.insert(sharedCredentials).values({ - hostAccessId, - originalCredentialId, - targetUserId, - encryptedUsername: "", - encryptedAuthType: "", - needsReEncryption: true, - }); - - databaseLogger.info("Created pending shared credential", { - operation: "create_pending_shared_credential", - hostAccessId, - targetUserId, - }); - } - - private async reEncryptSharedCredential( - sharedCredId: number, - userId: string, - ): Promise { - try { - const sharedCred = await db - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.id, sharedCredId)) - .limit(1); - - if (sharedCred.length === 0) { - databaseLogger.warn("Re-encrypt: shared credential not found", { - operation: "reencrypt_not_found", - sharedCredId, - }); - return; - } - - const cred = sharedCred[0]; - - const access = await db - .select() - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .where(eq(hostAccess.id, cred.hostAccessId)) - .limit(1); - - if (access.length === 0) { - databaseLogger.warn("Re-encrypt: host access not found", { - operation: "reencrypt_access_not_found", - sharedCredId, - }); - return; - } - - const ownerId = access[0].ssh_data.userId; - - const userDEK = DataCrypto.getUserDataKey(userId); - if (!userDEK) { - databaseLogger.warn("Re-encrypt: user DEK not available", { - operation: "reencrypt_user_offline", - sharedCredId, - userId, - }); - return; - } - - const ownerDEK = DataCrypto.getUserDataKey(ownerId); - let credentialData: CredentialData; - - if (ownerDEK) { - credentialData = await this.getDecryptedCredential( - cred.originalCredentialId, - ownerId, - ownerDEK, - ); - } else { - try { - credentialData = await this.getDecryptedCredentialViaSystemKey( - cred.originalCredentialId, - ); - } catch (error) { - databaseLogger.warn( - "Re-encrypt: system key decryption failed, credential may not be migrated yet", - { - operation: "reencrypt_system_key_failed", - sharedCredId, - error: error instanceof Error ? error.message : "Unknown error", - }, - ); - return; - } - } - - const encryptedForTarget = this.encryptCredentialForUser( - credentialData, - userId, - userDEK, - cred.hostAccessId, - ); - - await db - .update(sharedCredentials) - .set({ - ...encryptedForTarget, - needsReEncryption: false, - updatedAt: new Date().toISOString(), - }) - .where(eq(sharedCredentials.id, sharedCredId)); - } catch (error) { - databaseLogger.error("Failed to re-encrypt shared credential", error, { - operation: "reencrypt_shared_credential", - sharedCredId, - userId, - }); - } - } -} - -export { SharedCredentialManager }; diff --git a/src/backend/utils/shared-host-secrets-manager.ts b/src/backend/utils/shared-host-secrets-manager.ts new file mode 100644 index 00000000..be70acd2 --- /dev/null +++ b/src/backend/utils/shared-host-secrets-manager.ts @@ -0,0 +1,544 @@ +import { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentSharedHostSecretsRepository, +} from "../database/repositories/factory.js"; +import type { + SharedHostSecretRecord, + ShareProtocol, +} from "../database/repositories/shared-host-secrets-repository.js"; +import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js"; +import { DataCrypto } from "./data-crypto.js"; +import { FieldCrypto } from "./field-crypto.js"; +import { databaseLogger } from "./logger.js"; + +export interface SharedSecretData { + username?: string; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + domain?: string; +} + +interface ProtocolSnapshot { + protocol: ShareProtocol; + sourceType: "credential" | "inline"; + originalCredentialId: number | null; + data: SharedSecretData; +} + +function snapshotRecordId( + hostAccessId: number, + targetUserId: string, + protocol: ShareProtocol, +): string { + return `shared-${hostAccessId}-${targetUserId}-${protocol}`; +} + +// Mirrors the connection-type migration fallback in transformHostResponse(): +// old hosts only set connectionType, the per-protocol enable flags came later. +function enabledProtocols( + host: HostResolutionHostRecord, +): Record { + const ct = host.connectionType; + const rdp = !!host.enableRdp; + const vnc = !!host.enableVnc; + const telnet = !!host.enableTelnet; + const isMigratedNonSsh = !rdp && !vnc && !telnet && !!ct && ct !== "ssh"; + + return { + ssh: isMigratedNonSsh ? false : host.enableSsh !== false, + rdp: isMigratedNonSsh ? ct === "rdp" : rdp, + vnc: isMigratedNonSsh ? ct === "vnc" : vnc, + telnet: isMigratedNonSsh ? ct === "telnet" : telnet, + }; +} + +// Per-recipient copies of a shared host's connection secrets, re-encrypted +// under the recipient's DEK. Every enabled protocol gets its own snapshot; +// secret-less auth types (opkssh, vault, agent, none, ...) produce none. +class SharedHostSecretsManager { + private static instance: SharedHostSecretsManager; + + private constructor() {} + + static getInstance(): SharedHostSecretsManager { + if (!this.instance) { + this.instance = new SharedHostSecretsManager(); + } + return this.instance; + } + + async snapshotForUser( + hostAccessId: number, + hostId: number, + targetUserId: string, + ownerId: string, + ): Promise { + if (targetUserId === ownerId) return; + + try { + const targetDEK = DataCrypto.validateUserAccess(targetUserId); + DataCrypto.validateUserAccess(ownerId); + + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + ownerId, + ); + if (!host) { + throw new Error(`Host ${hostId} not found`); + } + + const snapshots = await this.collectProtocolSnapshots(host, ownerId); + const repository = createCurrentSharedHostSecretsRepository(); + + for (const snapshot of snapshots) { + const recordId = snapshotRecordId( + hostAccessId, + targetUserId, + snapshot.protocol, + ); + const encrypt = (value: string | undefined, fieldName: string) => + value + ? FieldCrypto.encryptField(value, targetDEK, recordId, fieldName) + : null; + + await repository.upsert({ + hostAccessId, + targetUserId, + protocol: snapshot.protocol, + sourceType: snapshot.sourceType, + originalCredentialId: snapshot.originalCredentialId, + encryptedUsername: encrypt(snapshot.data.username, "username"), + encryptedAuthType: snapshot.data.authType, + encryptedPassword: encrypt(snapshot.data.password, "password"), + encryptedKey: encrypt(snapshot.data.key, "key"), + encryptedKeyPassword: encrypt( + snapshot.data.keyPassword, + "key_password", + ), + encryptedKeyType: snapshot.data.keyType || null, + encryptedDomain: encrypt(snapshot.data.domain, "domain"), + }); + } + + await repository.deleteForHostAccessAndTarget( + hostAccessId, + targetUserId, + snapshots.map((snapshot) => snapshot.protocol), + ); + } catch (error) { + databaseLogger.error("Failed to snapshot shared host secrets", error, { + operation: "shared_host_secrets_snapshot", + hostAccessId, + hostId, + targetUserId, + }); + throw error; + } + } + + async snapshotForRole( + hostAccessId: number, + hostId: number, + roleId: number, + ownerId: string, + ): Promise { + const roleUserIds = + await createCurrentRoleRepository().listRoleUserIds(roleId); + + for (const userId of roleUserIds) { + try { + await this.snapshotForUser(hostAccessId, hostId, userId, ownerId); + } catch (error) { + databaseLogger.error( + "Failed to snapshot shared host secrets for role member", + error, + { + operation: "shared_host_secrets_snapshot_role", + hostAccessId, + roleId, + userId, + }, + ); + } + } + } + + async snapshotForRoleMember( + roleId: number, + targetUserId: string, + ): Promise { + const hostsSharedWithRole = + await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( + roleId, + ); + + for (const sharedHost of hostsSharedWithRole) { + try { + await this.snapshotForUser( + sharedHost.hostAccessId, + sharedHost.hostId, + targetUserId, + sharedHost.hostOwnerId, + ); + } catch (error) { + databaseLogger.error( + "Failed to snapshot shared host secrets for role member", + error, + { + operation: "shared_host_secrets_snapshot_role_member", + roleId, + targetUserId, + hostId: sharedHost.hostId, + }, + ); + } + } + } + + async snapshotForUserRoles(userId: string): Promise { + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + + for (const roleId of roleIds) { + await this.snapshotForRoleMember(roleId, userId); + } + } + + // Re-snapshot every active grant on a host. Called after the owner (or an + // editor) changes the host so recipients keep working secrets. + async resyncHost(hostId: number): Promise { + const hostResolutionRepository = createCurrentHostResolutionRepository(); + const ownerId = await hostResolutionRepository.findHostOwnerId(hostId); + if (!ownerId) return; + + const grants = + await createCurrentRbacAccessRepository().listActiveHostAccessGrants( + hostId, + ); + if (grants.length === 0) return; + + const roleRepository = createCurrentRoleRepository(); + + for (const grant of grants) { + const targetUserIds = grant.userId + ? [grant.userId] + : grant.roleId + ? await roleRepository.listRoleUserIds(grant.roleId) + : []; + + for (const targetUserId of targetUserIds) { + if (targetUserId === ownerId) continue; + try { + await this.snapshotForUser(grant.id, hostId, targetUserId, ownerId); + } catch (error) { + databaseLogger.warn( + "Skipping shared host secret resync for recipient", + { + operation: "shared_host_secrets_resync_skip", + hostId, + hostAccessId: grant.id, + targetUserId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + } + } + } + } + + // Re-snapshot every shared host that references a credential after the + // owner edits that credential. + async resyncHostsForCredential( + credentialId: number, + ownerId: string, + ): Promise { + try { + const hostIds = + await createCurrentSharedHostSecretsRepository().findHostIdsReferencingCredential( + ownerId, + credentialId, + ); + + for (const hostId of hostIds) { + await this.resyncHost(hostId); + } + } catch (error) { + databaseLogger.error( + "Failed to resync shared host secrets for credential", + error, + { + operation: "shared_host_secrets_resync_credential", + credentialId, + }, + ); + } + } + + async getSecretForUser( + hostId: number, + userId: string, + protocol: ShareProtocol, + ): Promise { + try { + const userDEK = DataCrypto.validateUserAccess(userId); + + const secret = + await createCurrentSharedHostSecretsRepository().findForHostUserProtocol( + hostId, + userId, + protocol, + ); + + if (!secret) return null; + + return this.decryptSnapshot(secret, userDEK); + } catch (error) { + databaseLogger.error("Failed to get shared host secret", error, { + operation: "shared_host_secrets_get", + hostId, + userId, + protocol, + }); + throw error; + } + } + + async deleteForHostAccess(hostAccessId: number): Promise { + try { + await createCurrentSharedHostSecretsRepository().deleteByHostAccessId( + hostAccessId, + ); + } catch (error) { + databaseLogger.error( + "Failed to delete shared host secrets for access grant", + error, + { + operation: "shared_host_secrets_delete_access", + hostAccessId, + }, + ); + } + } + + async deleteForUser(userId: string): Promise { + try { + await createCurrentSharedHostSecretsRepository().deleteByTargetUserId( + userId, + ); + } catch (error) { + databaseLogger.error( + "Failed to delete shared host secrets for user", + error, + { + operation: "shared_host_secrets_delete_user", + userId, + }, + ); + } + } + + async deleteForCredential(credentialId: number): Promise { + try { + await createCurrentSharedHostSecretsRepository().deleteByOriginalCredentialId( + credentialId, + ); + } catch (error) { + databaseLogger.error( + "Failed to delete shared host secrets for credential", + error, + { + operation: "shared_host_secrets_delete_credential", + credentialId, + }, + ); + } + } + + private async collectProtocolSnapshots( + host: HostResolutionHostRecord, + ownerId: string, + ): Promise { + const repository = createCurrentHostResolutionRepository(); + const enabled = enabledProtocols(host); + const snapshots: ProtocolSnapshot[] = []; + + if (enabled.ssh) { + if (host.credentialId) { + const credential = await repository.findCredentialByIdForUser( + host.credentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "ssh", + sourceType: "credential", + originalCredentialId: host.credentialId, + data: { + username: credential.username || undefined, + authType: credential.authType, + password: credential.password || undefined, + key: credential.privateKey || credential.key || undefined, + keyPassword: credential.keyPassword || undefined, + keyType: credential.keyType || undefined, + }, + }); + } + } else if ( + (host.authType === "password" && host.password) || + (host.authType === "key" && host.key) + ) { + snapshots.push({ + protocol: "ssh", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.username || undefined, + authType: host.authType, + password: host.password || undefined, + key: host.key || undefined, + keyPassword: host.keyPassword || undefined, + keyType: host.keyType || undefined, + }, + }); + } + } + + if (enabled.rdp) { + const rdpAuthType = + host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct"); + if (rdpAuthType === "credential" && host.rdpCredentialId) { + const credential = await repository.findCredentialByIdForUser( + host.rdpCredentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "rdp", + sourceType: "credential", + originalCredentialId: host.rdpCredentialId, + data: { + username: credential.username || undefined, + authType: "credential", + password: credential.password || undefined, + domain: host.rdpDomain || undefined, + }, + }); + } + } else if (host.rdpUser || host.rdpPassword) { + snapshots.push({ + protocol: "rdp", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.rdpUser || undefined, + authType: "direct", + password: host.rdpPassword || undefined, + domain: host.rdpDomain || undefined, + }, + }); + } + } + + if (enabled.vnc) { + const vncAuthType = + host.vncAuthType || (host.vncCredentialId ? "credential" : "direct"); + if (vncAuthType === "credential" && host.vncCredentialId) { + const credential = await repository.findCredentialByIdForUser( + host.vncCredentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "vnc", + sourceType: "credential", + originalCredentialId: host.vncCredentialId, + data: { + username: credential.username || undefined, + authType: "credential", + password: credential.password || undefined, + }, + }); + } + } else if (host.vncUser || host.vncPassword) { + snapshots.push({ + protocol: "vnc", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.vncUser || undefined, + authType: "direct", + password: host.vncPassword || undefined, + }, + }); + } + } + + if (enabled.telnet) { + const telnetAuthType = + host.telnetAuthType || + (host.telnetCredentialId ? "credential" : "direct"); + if (telnetAuthType === "credential" && host.telnetCredentialId) { + const credential = await repository.findCredentialByIdForUser( + host.telnetCredentialId, + ownerId, + ); + if (credential) { + snapshots.push({ + protocol: "telnet", + sourceType: "credential", + originalCredentialId: host.telnetCredentialId, + data: { + username: credential.username || undefined, + authType: "credential", + password: credential.password || undefined, + }, + }); + } + } else if (host.telnetUser || host.telnetPassword) { + snapshots.push({ + protocol: "telnet", + sourceType: "inline", + originalCredentialId: null, + data: { + username: host.telnetUser || undefined, + authType: "direct", + password: host.telnetPassword || undefined, + }, + }); + } + } + + return snapshots; + } + + private decryptSnapshot( + secret: SharedHostSecretRecord, + userDEK: Buffer, + ): SharedSecretData { + const recordId = snapshotRecordId( + secret.hostAccessId, + secret.targetUserId, + secret.protocol as ShareProtocol, + ); + const decrypt = (value: string | null, fieldName: string) => + value + ? FieldCrypto.decryptField(value, userDEK, recordId, fieldName) + : undefined; + + return { + username: decrypt(secret.encryptedUsername, "username"), + authType: secret.encryptedAuthType || "password", + password: decrypt(secret.encryptedPassword, "password"), + key: decrypt(secret.encryptedKey, "key"), + keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"), + keyType: secret.encryptedKeyType || undefined, + domain: decrypt(secret.encryptedDomain, "domain"), + }; + } +} + +export { SharedHostSecretsManager }; diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts deleted file mode 100644 index ddf8df75..00000000 --- a/src/backend/utils/simple-db-ops.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; -import { DataCrypto } from "./data-crypto.js"; -import type { SQLiteTable } from "drizzle-orm/sqlite-core"; -import type { SQL } from "drizzle-orm"; - -type TableName = - | "users" - | "ssh_data" - | "ssh_credentials" - | "termix_identity_ca" - | "recent_activity" - | "socks5_proxy_presets"; - -class SimpleDBOps { - static async insert>( - table: SQLiteTable, - tableName: TableName, - data: T, - userId: string, - ): Promise { - const userDataKey = DataCrypto.validateUserAccess(userId); - - const tempId = data.id || `temp-${userId}-${Date.now()}`; - const dataWithTempId = { ...data, id: tempId }; - - const encryptedData = DataCrypto.encryptRecord( - tableName, - dataWithTempId, - userId, - userDataKey, - ); - - if (tableName === "ssh_credentials") { - const { SystemCrypto } = await import("./system-crypto.js"); - const systemCrypto = SystemCrypto.getInstance(); - const systemKey = await systemCrypto.getCredentialSharingKey(); - - const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( - tableName, - dataWithTempId, - systemKey, - ); - - Object.assign(encryptedData, systemEncrypted); - } - - if (!data.id) { - delete encryptedData.id; - } - - const result = await getDb() - .insert(table) - .values(encryptedData) - .returning(); - - DatabaseSaveTrigger.triggerSave(`insert_${tableName}`); - - const decryptedResult = DataCrypto.decryptRecord( - tableName, - result[0], - userId, - userDataKey, - ); - - return decryptedResult as T; - } - - static async select>( - query: unknown, - tableName: TableName, - userId: string, - ): Promise { - const userDataKey = DataCrypto.getUserDataKey(userId); - if (!userDataKey) { - return []; - } - - const results = await query; - - const decryptedResults = DataCrypto.decryptRecords( - tableName, - results as T[], - userId, - userDataKey, - ); - - return decryptedResults; - } - - static async selectOne>( - query: unknown, - tableName: TableName, - userId: string, - ): Promise { - const userDataKey = DataCrypto.getUserDataKey(userId); - if (!userDataKey) { - return undefined; - } - - const result = await query; - if (!result) return undefined; - - const decryptedResult = DataCrypto.decryptRecord( - tableName, - result as T, - userId, - userDataKey, - ); - - return decryptedResult; - } - - static async update>( - table: SQLiteTable, - tableName: TableName, - where: unknown, - data: Partial, - userId: string, - ): Promise { - const userDataKey = DataCrypto.validateUserAccess(userId); - - const encryptedData = DataCrypto.encryptRecord( - tableName, - data, - userId, - userDataKey, - ); - - if (tableName === "ssh_credentials") { - const { SystemCrypto } = await import("./system-crypto.js"); - const systemCrypto = SystemCrypto.getInstance(); - const systemKey = await systemCrypto.getCredentialSharingKey(); - - const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( - tableName, - data, - systemKey, - ); - - Object.assign(encryptedData, systemEncrypted); - } - - const result = await getDb() - .update(table) - .set(encryptedData) - .where(where as SQL | undefined) - .returning(); - - DatabaseSaveTrigger.triggerSave(`update_${tableName}`); - - const decryptedResults = DataCrypto.decryptRecords( - tableName, - result, - userId, - userDataKey, - ); - - return decryptedResults as T[]; - } - - static async delete( - table: SQLiteTable, - tableName: TableName, - where: unknown, - ): Promise { - const result = await getDb() - .delete(table) - .where(where as SQL | undefined) - .returning(); - - DatabaseSaveTrigger.triggerSave(`delete_${tableName}`); - - return result; - } - - static async healthCheck(userId: string): Promise { - return DataCrypto.canUserAccessData(userId); - } - - static isUserDataUnlocked(userId: string): boolean { - return DataCrypto.getUserDataKey(userId) !== null; - } - - static async selectEncrypted(query: unknown): Promise { - const results = await query; - - return results as unknown[]; - } -} - -export { SimpleDBOps, type TableName }; diff --git a/src/backend/swagger.ts b/src/backend/utils/swagger.ts similarity index 88% rename from src/backend/swagger.ts rename to src/backend/utils/swagger.ts index b1af2f2b..83a4367a 100644 --- a/src/backend/swagger.ts +++ b/src/backend/utils/swagger.ts @@ -2,7 +2,7 @@ import swaggerJSDoc from "@deadendjs/swagger-jsdoc"; import path from "path"; import { fileURLToPath } from "url"; import { promises as fs } from "fs"; -import { systemLogger } from "./utils/logger.js"; +import { systemLogger } from "./logger.js"; type SwaggerJSDocOptions = Parameters[0]; @@ -122,10 +122,12 @@ const swaggerOptions: SwaggerJSDocOptions = { ], }, apis: [ - path.join(__dirname, "database", "routes", "*.js").replace(/\\/g, "/"), - path.join(__dirname, "dashboard.js").replace(/\\/g, "/"), - path.join(__dirname, "ssh", "*.js").replace(/\\/g, "/"), - path.join(__dirname, "guacamole", "routes.js").replace(/\\/g, "/"), + path + .join(__dirname, "..", "database", "routes", "*.js") + .replace(/\\/g, "/"), + path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"), + path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"), + path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"), ], }; @@ -137,7 +139,14 @@ async function generateOpenAPISpec() { const swaggerSpec = await swaggerJSDoc(swaggerOptions); - const outputPath = path.join(__dirname, "..", "..", "..", "openapi.json"); + const outputPath = path.join( + __dirname, + "..", + "..", + "..", + "..", + "openapi.json", + ); await fs.writeFile( outputPath, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 915b8248..ff3b6c3f 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -9,7 +9,6 @@ class SystemCrypto { private databaseKey: Buffer | null = null; private encryptionKey: Buffer | null = null; private internalAuthToken: string | null = null; - private credentialSharingKey: Buffer | null = null; private constructor() {} @@ -196,52 +195,6 @@ class SystemCrypto { return this.internalAuthToken!; } - async initializeCredentialSharingKey(): Promise { - try { - const dataDir = process.env.DATA_DIR || "./db/data"; - const envPath = path.join(dataDir, ".env"); - - const envKey = process.env.CREDENTIAL_SHARING_KEY; - if (envKey && envKey.length >= 64) { - this.credentialSharingKey = Buffer.from(envKey, "hex"); - return; - } - - try { - const envContent = await fs.readFile(envPath, "utf8"); - const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m); - if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) { - this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex"); - process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1]; - return; - } - } catch { - // expected - env file may not exist - } - - await this.generateAndGuideCredentialSharingKey(); - } catch (error) { - databaseLogger.error( - "Failed to initialize credential sharing key", - error, - { - operation: "cred_sharing_key_init_failed", - dataDir: process.env.DATA_DIR || "./db/data", - }, - ); - throw new Error("Credential sharing key initialization failed", { - cause: error, - }); - } - } - - async getCredentialSharingKey(): Promise { - if (!this.credentialSharingKey) { - await this.initializeCredentialSharingKey(); - } - return this.credentialSharingKey!; - } - private async generateAndGuideUser(): Promise { const newSecret = crypto.randomBytes(32).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); @@ -311,26 +264,6 @@ class SystemCrypto { ); } - private async generateAndGuideCredentialSharingKey(): Promise { - const newKey = crypto.randomBytes(32); - const newKeyHex = newKey.toString("hex"); - const instanceId = crypto.randomBytes(8).toString("hex"); - - this.credentialSharingKey = newKey; - - await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex); - - databaseLogger.success( - "Credential sharing key auto-generated and saved to .env", - { - operation: "cred_sharing_key_auto_generated", - instanceId, - envVarName: "CREDENTIAL_SHARING_KEY", - note: "Used for offline credential sharing - no restart required", - }, - ); - } - async validateJWTSecret(): Promise { try { const secret = await this.getJWTSecret(); diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts deleted file mode 100644 index 5ff3ee22..00000000 --- a/src/backend/utils/user-crypto.ts +++ /dev/null @@ -1,758 +0,0 @@ -import crypto from "crypto"; -import { getDb } from "../database/db/index.js"; -import { settings } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; -import { databaseLogger } from "./logger.js"; - -interface KEKSalt { - salt: string; - iterations: number; - algorithm: string; - createdAt: string; -} - -interface EncryptedDEK { - data: string; - iv: string; - tag: string; - algorithm: string; - createdAt: string; -} - -interface UserSession { - dataKey: Buffer; - expiresAt: number; - lastActivity?: number; -} - -class UserCrypto { - private static instance: UserCrypto; - private userSessions: Map = new Map(); - private sessionExpiredCallback?: (userId: string) => void; - - private static readonly PBKDF2_ITERATIONS = 100000; - private static readonly KEK_LENGTH = 32; - private static readonly DEK_LENGTH = 32; - - private constructor() { - setInterval( - () => { - this.cleanupExpiredSessions(); - }, - 5 * 60 * 1000, - ); - } - - static getInstance(): UserCrypto { - if (!this.instance) { - this.instance = new UserCrypto(); - } - return this.instance; - } - - setSessionExpiredCallback(callback: (userId: string) => void): void { - this.sessionExpiredCallback = callback; - } - - async setupUserEncryption(userId: string, password: string): Promise { - const kekSalt = await this.generateKEKSalt(); - await this.storeKEKSalt(userId, kekSalt); - - const KEK = this.deriveKEK(password, kekSalt); - const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); - const encryptedDEK = this.encryptDEK(DEK, KEK); - await this.storeEncryptedDEK(userId, encryptedDEK); - - KEK.fill(0); - DEK.fill(0); - } - - async setupOIDCUserEncryption( - userId: string, - sessionDurationMs: number, - ): Promise { - const existingEncryptedDEK = await this.getEncryptedDEK(userId); - - let DEK: Buffer; - - if (existingEncryptedDEK) { - const systemKey = this.deriveOIDCSystemKey(userId); - DEK = this.decryptDEK(existingEncryptedDEK, systemKey); - systemKey.fill(0); - } else { - DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); - const systemKey = this.deriveOIDCSystemKey(userId); - - try { - const encryptedDEK = this.encryptDEK(DEK, systemKey); - await this.storeEncryptedDEK(userId, encryptedDEK); - - const storedEncryptedDEK = await this.getEncryptedDEK(userId); - if ( - storedEncryptedDEK && - storedEncryptedDEK.data !== encryptedDEK.data - ) { - DEK.fill(0); - DEK = this.decryptDEK(storedEncryptedDEK, systemKey); - } else if (!storedEncryptedDEK) { - throw new Error("Failed to store and retrieve user encryption key."); - } - } finally { - systemKey.fill(0); - } - } - - const now = Date.now(); - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - } - - async authenticateUser( - userId: string, - password: string, - sessionDurationMs: number, - ): Promise { - try { - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) return false; - - const KEK = this.deriveKEK(password, kekSalt); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) { - KEK.fill(0); - return false; - } - - const DEK = this.decryptDEK(encryptedDEK, KEK); - KEK.fill(0); - - if (!DEK || DEK.length === 0) { - databaseLogger.error("DEK is empty or invalid after decryption", { - operation: "user_crypto_auth_debug", - userId, - dekLength: DEK ? DEK.length : 0, - }); - return false; - } - - const now = Date.now(); - - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - - return true; - } catch (error) { - databaseLogger.warn("User authentication failed", { - operation: "user_crypto_auth_failed", - userId, - error: error instanceof Error ? error.message : "Unknown", - }); - return false; - } - } - - async authenticateOIDCUser( - userId: string, - sessionDurationMs: number, - ): Promise { - try { - const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId); - - if (oidcEncryptedDEK) { - const systemKey = this.deriveOIDCSystemKey(userId); - const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey); - systemKey.fill(0); - - if (!DEK || DEK.length === 0) { - databaseLogger.error( - "Failed to decrypt OIDC DEK for dual-auth user", - { - operation: "oidc_auth_dual_decrypt_failed", - userId, - }, - ); - return false; - } - - const now = Date.now(); - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - return true; - } - - const kekSalt = await this.getKEKSalt(userId); - const encryptedDEK = await this.getEncryptedDEK(userId); - - if (!kekSalt || !encryptedDEK) { - await this.setupOIDCUserEncryption(userId, sessionDurationMs); - return true; - } - - const systemKey = this.deriveOIDCSystemKey(userId); - const DEK = this.decryptDEK(encryptedDEK, systemKey); - systemKey.fill(0); - - if (!DEK || DEK.length === 0) { - await this.setupOIDCUserEncryption(userId, sessionDurationMs); - return true; - } - - const now = Date.now(); - - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - - return true; - } catch (error) { - databaseLogger.error("OIDC authentication failed", error, { - operation: "oidc_auth_error", - userId, - error: error instanceof Error ? error.message : "Unknown", - }); - await this.setupOIDCUserEncryption(userId, sessionDurationMs); - return true; - } - } - - async setupWebAuthnUserEncryption(userId: string): Promise { - const existingDEK = this.getUserDataKey(userId); - - if (!existingDEK) { - throw new Error( - "Cannot enable WebAuthn encryption - user session not active. Please log in first.", - ); - } - - const systemKey = this.deriveWebAuthnSystemKey(userId); - const encryptedDEK = this.encryptDEK(existingDEK, systemKey); - systemKey.fill(0); - - await this.storeWebAuthnEncryptedDEK(userId, encryptedDEK); - } - - async authenticateWebAuthnUser( - userId: string, - sessionDurationMs: number, - ): Promise { - try { - const encryptedDEK = await this.getWebAuthnEncryptedDEK(userId); - if (!encryptedDEK) { - return false; - } - - const systemKey = this.deriveWebAuthnSystemKey(userId); - const DEK = this.decryptDEK(encryptedDEK, systemKey); - systemKey.fill(0); - - if (!DEK || DEK.length === 0) { - return false; - } - - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: Date.now() + sessionDurationMs, - }); - - DEK.fill(0); - return true; - } catch (error) { - databaseLogger.warn("WebAuthn authentication failed", { - operation: "webauthn_auth_failed", - userId, - error: error instanceof Error ? error.message : "Unknown", - }); - return false; - } - } - - getUserDataKey(userId: string): Buffer | null { - const session = this.userSessions.get(userId); - if (!session) { - return null; - } - - const now = Date.now(); - - if (now > session.expiresAt) { - this.userSessions.delete(userId); - session.dataKey.fill(0); - if (this.sessionExpiredCallback) { - this.sessionExpiredCallback(userId); - } - return null; - } - - return session.dataKey; - } - - restoreUserDataKey(userId: string, dataKey: Buffer, expiresAt: number): void { - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(dataKey), - expiresAt, - lastActivity: Date.now(), - }); - } - - logoutUser(userId: string): void { - const session = this.userSessions.get(userId); - if (session) { - session.dataKey.fill(0); - this.userSessions.delete(userId); - } - } - - isUserUnlocked(userId: string): boolean { - return this.getUserDataKey(userId) !== null; - } - - async changeUserPassword( - userId: string, - oldPassword: string, - newPassword: string, - ): Promise { - try { - const isValid = await this.validatePassword(userId, oldPassword); - if (!isValid) return false; - - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) return false; - - const oldKEK = this.deriveKEK(oldPassword, kekSalt); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) return false; - - const DEK = this.decryptDEK(encryptedDEK, oldKEK); - - const newKekSalt = await this.generateKEKSalt(); - const newKEK = this.deriveKEK(newPassword, newKekSalt); - - const newEncryptedDEK = this.encryptDEK(DEK, newKEK); - - await this.storeKEKSalt(userId, newKekSalt); - await this.storeEncryptedDEK(userId, newEncryptedDEK); - - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - - oldKEK.fill(0); - newKEK.fill(0); - DEK.fill(0); - - return true; - } catch (error) { - databaseLogger.error("Password change failed", error, { - operation: "password_change_error", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - return false; - } - } - - async resetUserPasswordWithPreservedDEK( - userId: string, - newPassword: string, - ): Promise { - try { - const existingDEK = this.getUserDataKey(userId); - if (!existingDEK) { - return false; - } - - const newKekSalt = await this.generateKEKSalt(); - const newKEK = this.deriveKEK(newPassword, newKekSalt); - - const newEncryptedDEK = this.encryptDEK(existingDEK, newKEK); - - await this.storeKEKSalt(userId, newKekSalt); - await this.storeEncryptedDEK(userId, newEncryptedDEK); - - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - - newKEK.fill(0); - - const session = this.userSessions.get(userId); - if (session) { - session.lastActivity = Date.now(); - } - - return true; - } catch (error) { - databaseLogger.error("Password reset with preserved DEK failed", error, { - operation: "password_reset_preserve_error", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - return false; - } - } - - async convertToOIDCEncryption(userId: string): Promise { - try { - const existingEncryptedDEK = await this.getEncryptedDEK(userId); - const existingKEKSalt = await this.getKEKSalt(userId); - - if (!existingEncryptedDEK && !existingKEKSalt) { - databaseLogger.warn("No existing encryption to convert for user", { - operation: "convert_to_oidc_encryption_skip", - userId, - }); - return; - } - - const existingDEK = this.getUserDataKey(userId); - - if (!existingDEK) { - throw new Error( - "Cannot convert to OIDC encryption - user session not active. Please log in with password first.", - ); - } - - const systemKey = this.deriveOIDCSystemKey(userId); - const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); - systemKey.fill(0); - - const key = `user_encrypted_dek_oidc_${userId}`; - const value = JSON.stringify(oidcEncryptedDEK); - - const { getDb } = await import("../database/db/index.js"); - const { settings } = await import("../database/db/schema.js"); - const { eq } = await import("drizzle-orm"); - - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } - - databaseLogger.info( - "Converted user encryption to dual-auth (password + OIDC)", - { - operation: "convert_to_oidc_encryption_preserved", - userId, - }, - ); - - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (error) { - databaseLogger.error("Failed to convert to OIDC encryption", error, { - operation: "convert_to_oidc_encryption_error", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - throw error; - } - } - - private async validatePassword( - userId: string, - password: string, - ): Promise { - try { - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) return false; - - const KEK = this.deriveKEK(password, kekSalt); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) return false; - - const DEK = this.decryptDEK(encryptedDEK, KEK); - - KEK.fill(0); - DEK.fill(0); - - return true; - } catch { - return false; - } - } - - private cleanupExpiredSessions(): void { - const now = Date.now(); - const expiredUsers: string[] = []; - - for (const [userId, session] of this.userSessions.entries()) { - if (now > session.expiresAt) { - session.dataKey.fill(0); - expiredUsers.push(userId); - } - } - - expiredUsers.forEach((userId) => { - this.userSessions.delete(userId); - }); - } - - private async generateKEKSalt(): Promise { - return { - salt: crypto.randomBytes(32).toString("hex"), - iterations: UserCrypto.PBKDF2_ITERATIONS, - algorithm: "pbkdf2-sha256", - createdAt: new Date().toISOString(), - }; - } - - private deriveKEK(password: string, kekSalt: KEKSalt): Buffer { - return crypto.pbkdf2Sync( - password, - Buffer.from(kekSalt.salt, "hex"), - kekSalt.iterations, - UserCrypto.KEK_LENGTH, - "sha256", - ); - } - - private deriveOIDCSystemKey(userId: string): Buffer { - const systemSecret = - process.env.OIDC_SYSTEM_SECRET || "termix-oidc-system-secret-default"; - const salt = Buffer.from(userId, "utf8"); - return crypto.pbkdf2Sync( - systemSecret, - salt, - 100000, - UserCrypto.KEK_LENGTH, - "sha256", - ); - } - - private deriveWebAuthnSystemKey(userId: string): Buffer { - const systemSecret = - process.env.WEBAUTHN_SYSTEM_SECRET || - process.env.OIDC_SYSTEM_SECRET || - "termix-webauthn-system-secret-default"; - const salt = Buffer.from(`webauthn:${userId}`, "utf8"); - return crypto.pbkdf2Sync( - systemSecret, - salt, - 100000, - UserCrypto.KEK_LENGTH, - "sha256", - ); - } - - private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); - - let encrypted = cipher.update(dek); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - - return { - data: encrypted.toString("hex"), - iv: iv.toString("hex"), - tag: tag.toString("hex"), - algorithm: "aes-256-gcm", - createdAt: new Date().toISOString(), - }; - } - - private decryptDEK(encryptedDEK: EncryptedDEK, kek: Buffer): Buffer { - const decipher = crypto.createDecipheriv( - "aes-256-gcm", - kek, - Buffer.from(encryptedDEK.iv, "hex"), - ); - - decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex")); - let decrypted = decipher.update(Buffer.from(encryptedDEK.data, "hex")); - decrypted = Buffer.concat([decrypted, decipher.final()]); - - return decrypted; - } - - private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise { - const key = `user_kek_salt_${userId}`; - const value = JSON.stringify(kekSalt); - - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } - } - - private async getKEKSalt(userId: string): Promise { - try { - const key = `user_kek_salt_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (result.length === 0) { - return null; - } - - return JSON.parse(result[0].value); - } catch { - return null; - } - } - - private async storeEncryptedDEK( - userId: string, - encryptedDEK: EncryptedDEK, - ): Promise { - const key = `user_encrypted_dek_${userId}`; - const value = JSON.stringify(encryptedDEK); - - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } - } - - private async getEncryptedDEK(userId: string): Promise { - try { - const key = `user_encrypted_dek_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (result.length === 0) { - return null; - } - - return JSON.parse(result[0].value); - } catch { - return null; - } - } - - private async getOIDCEncryptedDEK( - userId: string, - ): Promise { - try { - const key = `user_encrypted_dek_oidc_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (result.length === 0) { - return null; - } - - return JSON.parse(result[0].value); - } catch { - return null; - } - } - - private async storeWebAuthnEncryptedDEK( - userId: string, - encryptedDEK: EncryptedDEK, - ): Promise { - const key = `user_encrypted_dek_webauthn_${userId}`; - const value = JSON.stringify(encryptedDEK); - - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } - } - - private async getWebAuthnEncryptedDEK( - userId: string, - ): Promise { - try { - const key = `user_encrypted_dek_webauthn_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (result.length === 0) { - return null; - } - - return JSON.parse(result[0].value); - } catch { - return null; - } - } -} - -export { UserCrypto, type KEKSalt, type EncryptedDEK }; diff --git a/src/backend/utils/user-data-export.ts b/src/backend/utils/user-data-export.ts index c0b51f36..42c39c79 100644 --- a/src/backend/utils/user-data-export.ts +++ b/src/backend/utils/user-data-export.ts @@ -1,15 +1,10 @@ -import { getDb } from "../database/db/index.js"; import { - users, - hosts, - sshCredentials, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - transferRecent, - dismissedAlerts, -} from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; + createCurrentDismissedAlertRepository, + createCurrentFileManagerBookmarkRepository, + createCurrentTransferRecentRepository, + createCurrentUserDataExportRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger } from "./logger.js"; @@ -54,16 +49,11 @@ class UserDataExport { } = options; try { - const user = await getDb() - .select() - .from(users) - .where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { throw new Error(`User not found: ${userId}`); } - const userRecord = user[0]; - let userDataKey: Buffer | null = null; if (format === "plaintext") { userDataKey = DataCrypto.getUserDataKey(userId); @@ -74,10 +64,8 @@ class UserDataExport { } } - const sshHosts = await getDb() - .select() - .from(hosts) - .where(eq(hosts.userId, userId)); + const exportRepository = createCurrentUserDataExportRepository(); + const sshHosts = await exportRepository.listHostsByUserId(userId); const processedSshHosts = format === "plaintext" && userDataKey ? sshHosts.map((host) => @@ -87,10 +75,8 @@ class UserDataExport { let sshCredentialsData: unknown[] = []; if (includeCredentials) { - const credentials = await getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); + const credentials = + await exportRepository.listCredentialsByUserId(userId); sshCredentialsData = format === "plaintext" && userDataKey ? credentials.map((cred) => @@ -106,28 +92,20 @@ class UserDataExport { const [recentFiles, pinnedFiles, shortcuts, transferRecentData] = 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)), - getDb() - .select() - .from(transferRecent) - .where(eq(transferRecent.userId, userId)), + createCurrentFileManagerBookmarkRepository().listRecentByUserId( + userId, + ), + createCurrentFileManagerBookmarkRepository().listPinnedByUserId( + userId, + ), + createCurrentFileManagerBookmarkRepository().listShortcutsByUserId( + userId, + ), + createCurrentTransferRecentRepository().listByUserId(userId), ]); - const alerts = await getDb() - .select() - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); + const alerts = + await createCurrentDismissedAlertRepository().listByUserId(userId); const exportData: UserExportData = { version: this.EXPORT_VERSION, diff --git a/src/backend/utils/user-encryption-migration-store.ts b/src/backend/utils/user-encryption-migration-store.ts new file mode 100644 index 00000000..112eb326 --- /dev/null +++ b/src/backend/utils/user-encryption-migration-store.ts @@ -0,0 +1,150 @@ +import { getCurrentRepositorySqlite } from "../database/repositories/factory.js"; + +export interface UserEncryptionMigrationRecord { + id: number | string; + [key: string]: unknown; +} + +export interface UserEncryptionMigrationStore { + listHostRecords(userId: string): UserEncryptionMigrationRecord[]; + listCredentialRecords(userId: string): UserEncryptionMigrationRecord[]; + getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined; + updateHostSensitiveFields( + recordId: number | string, + record: Record, + ): void; + updateCredentialSensitiveFields( + recordId: number | string, + record: Record, + ): void; + updateUserSensitiveFields( + userId: string, + record: Record, + ): void; + updatePasswordResetFields( + table: "ssh_data" | "ssh_credentials" | "users", + recordId: number | string, + fields: string[], + record: Record, + ): void; +} + +export interface LegacyDatabaseInstance { + prepare: (sql: string) => { + all: (param?: unknown) => UserEncryptionMigrationRecord[]; + get: (param?: unknown) => UserEncryptionMigrationRecord | undefined; + run: (...params: unknown[]) => unknown; + }; +} + +export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigrationStore { + constructor(private readonly db: LegacyDatabaseInstance) {} + + listHostRecords(userId: string): UserEncryptionMigrationRecord[] { + return this.db + .prepare("SELECT * FROM ssh_data WHERE user_id = ?") + .all(userId); + } + + listCredentialRecords(userId: string): UserEncryptionMigrationRecord[] { + return this.db + .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") + .all(userId); + } + + getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined { + return this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId); + } + + updateHostSensitiveFields( + recordId: number | string, + record: Record, + ): void { + this.db + .prepare( + ` + UPDATE ssh_data + SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + ) + .run( + record.password || null, + record.key || null, + record.key_password || null, + record.key_type || null, + record.autostart_password || null, + record.autostart_key || null, + record.autostart_key_password || null, + record.sudo_password || null, + recordId, + ); + } + + updateCredentialSensitiveFields( + recordId: number | string, + record: Record, + ): void { + this.db + .prepare( + ` + UPDATE ssh_credentials + SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + ) + .run( + record.password || null, + record.key || null, + record.key_password || null, + record.private_key || null, + record.public_key || null, + record.key_type || null, + recordId, + ); + } + + updateUserSensitiveFields( + userId: string, + record: Record, + ): void { + this.db + .prepare( + ` + UPDATE users + SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ? + WHERE id = ? + `, + ) + .run( + record.totp_secret || null, + record.totp_backup_codes || null, + record.client_secret || null, + record.oidc_identifier || null, + userId, + ); + } + + updatePasswordResetFields( + table: "ssh_data" | "ssh_credentials" | "users", + recordId: number | string, + fields: string[], + record: Record, + ): void { + const setClause = fields.map((field) => `${field} = ?`).join(", "); + const updateQuery = + table === "users" + ? `UPDATE ${table} SET ${setClause} WHERE id = ?` + : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; + const updateValues = fields.map((field) => record[field]); + updateValues.push(recordId); + + this.db.prepare(updateQuery).run(...updateValues); + } +} + +export async function createCurrentUserEncryptionMigrationStore(): Promise { + return new RawSqliteUserEncryptionMigrationStore( + getCurrentRepositorySqlite(), + ); +} diff --git a/src/backend/utils/user-keys.ts b/src/backend/utils/user-keys.ts new file mode 100644 index 00000000..e33fd4a9 --- /dev/null +++ b/src/backend/utils/user-keys.ts @@ -0,0 +1,225 @@ +import crypto from "crypto"; +import { SystemCrypto } from "./system-crypto.js"; +import { + createCurrentSettingsRepository, + getCurrentSettingValue, +} from "../database/repositories/factory.js"; +import { authLogger } from "./logger.js"; + +const DEK_LENGTH = 32; +const WRAP_VERSION = 3; +const WRAP_ALGORITHM = "aes-256-gcm"; +const WRAP_INFO_PREFIX = "termix:dek-wrap:v3:"; +const CACHE_TTL_MS = 15 * 60 * 1000; + +export type UserKeyUnavailableReason = "pending_migration" | "missing"; + +export class UserKeyUnavailableError extends Error { + constructor( + readonly userId: string, + readonly reason: UserKeyUnavailableReason, + ) { + super( + reason === "pending_migration" + ? `User ${userId} data stays locked until their next login migrates their encryption key` + : `User ${userId} has no data encryption key`, + ); + this.name = "UserKeyUnavailableError"; + } +} + +interface WrappedDek { + v: number; + alg: string; + iv: string; + ct: string; + tag: string; + createdAt: string; +} + +interface CacheEntry { + dek: Buffer; + expiresAt: number; +} + +export function userDekSettingsKey(userId: string): string { + return `user_dek_v3_${userId}`; +} + +const LEGACY_WRAP_KEY_PREFIXES = [ + "user_encrypted_dek_", + "user_kek_salt_", +] as const; + +export class UserKeyManager { + private static instance: UserKeyManager | null = null; + + private masterKey: Buffer | null = null; + private cache = new Map(); + + static getInstance(): UserKeyManager { + if (!UserKeyManager.instance) { + UserKeyManager.instance = new UserKeyManager(); + } + return UserKeyManager.instance; + } + + async initialize(masterKey?: Buffer): Promise { + this.masterKey = + masterKey ?? (await SystemCrypto.getInstance().getEncryptionKey()); + this.cache.clear(); + } + + getUserDEK(userId: string): Buffer { + const cached = this.cache.get(userId); + if (cached && cached.expiresAt > Date.now()) { + return cached.dek; + } + + const raw = getCurrentSettingValue(userDekSettingsKey(userId)); + if (!raw) { + throw new UserKeyUnavailableError( + userId, + this.hasLegacyWrap(userId) ? "pending_migration" : "missing", + ); + } + + const dek = this.unwrap(userId, raw); + this.cacheDek(userId, dek); + return dek; + } + + tryGetUserDEK(userId: string): Buffer | null { + try { + return this.getUserDEK(userId); + } catch (error) { + if (error instanceof UserKeyUnavailableError) { + return null; + } + throw error; + } + } + + hasUserDEK(userId: string): boolean { + if (this.cache.has(userId)) return true; + return getCurrentSettingValue(userDekSettingsKey(userId)) !== null; + } + + async createUserDEK(userId: string): Promise { + if (this.hasUserDEK(userId)) { + throw new Error(`User ${userId} already has a data encryption key`); + } + + const dek = crypto.randomBytes(DEK_LENGTH); + await this.persistDEK(userId, dek); + return dek; + } + + async persistDEK(userId: string, dek: Buffer): Promise { + if (dek.length !== DEK_LENGTH) { + throw new Error(`DEK must be ${DEK_LENGTH} bytes`); + } + + const wrapped = this.wrap(userId, dek); + await createCurrentSettingsRepository().upsert( + userDekSettingsKey(userId), + JSON.stringify(wrapped), + ); + this.cacheDek(userId, dek); + } + + async rotateUserDEK(userId: string): Promise { + const dek = crypto.randomBytes(DEK_LENGTH); + await this.persistDEK(userId, dek); + authLogger.warn(`Replaced data encryption key for user ${userId}`, { + operation: "user_dek_rotated", + userId, + }); + return dek; + } + + async deleteUserDEK(userId: string): Promise { + await createCurrentSettingsRepository().delete(userDekSettingsKey(userId)); + this.invalidate(userId); + } + + invalidate(userId: string): void { + this.cache.delete(userId); + } + + clearCache(): void { + this.cache.clear(); + } + + private hasLegacyWrap(userId: string): boolean { + return LEGACY_WRAP_KEY_PREFIXES.some( + (prefix) => getCurrentSettingValue(`${prefix}${userId}`) !== null, + ); + } + + private cacheDek(userId: string, dek: Buffer): void { + this.cache.set(userId, { dek, expiresAt: Date.now() + CACHE_TTL_MS }); + } + + private deriveWrapKey(userId: string): Buffer { + if (!this.masterKey) { + throw new Error("UserKeyManager is not initialized"); + } + + return Buffer.from( + crypto.hkdfSync( + "sha256", + this.masterKey, + Buffer.alloc(0), + `${WRAP_INFO_PREFIX}${userId}`, + DEK_LENGTH, + ), + ); + } + + private wrap(userId: string, dek: Buffer): WrappedDek { + const wrapKey = this.deriveWrapKey(userId); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(WRAP_ALGORITHM, wrapKey, iv); + cipher.setAAD(Buffer.from(userId, "utf8")); + const ct = Buffer.concat([cipher.update(dek), cipher.final()]); + + return { + v: WRAP_VERSION, + alg: WRAP_ALGORITHM, + iv: iv.toString("base64"), + ct: ct.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + createdAt: new Date().toISOString(), + }; + } + + private unwrap(userId: string, raw: string): Buffer { + let wrapped: WrappedDek; + try { + wrapped = JSON.parse(raw) as WrappedDek; + } catch { + throw new Error(`Stored key wrap for user ${userId} is not valid JSON`); + } + + if (wrapped.v !== WRAP_VERSION || wrapped.alg !== WRAP_ALGORITHM) { + throw new Error( + `Unsupported key wrap (v=${wrapped.v}, alg=${wrapped.alg}) for user ${userId}`, + ); + } + + const wrapKey = this.deriveWrapKey(userId); + const decipher = crypto.createDecipheriv( + WRAP_ALGORITHM, + wrapKey, + Buffer.from(wrapped.iv, "base64"), + ); + decipher.setAAD(Buffer.from(userId, "utf8")); + decipher.setAuthTag(Buffer.from(wrapped.tag, "base64")); + + return Buffer.concat([ + decipher.update(Buffer.from(wrapped.ct, "base64")), + decipher.final(), + ]); + } +} diff --git a/src/main.tsx b/src/main.tsx index 00ed6260..1e4c8894 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ import { Toaster } from "@/components/sonner"; import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth"; import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios"; import { applyAccentColor, applyFontSize } from "@/lib/theme"; +import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom"; import type { FontSizeId } from "@/types/ui-types"; import { useServiceWorker } from "@/hooks/use-service-worker"; import { useTranslation } from "react-i18next"; @@ -340,6 +341,8 @@ function RootApp() { return ; } +installElectronWheelZoomGuard(); + prepareClientCacheVersion().finally(() => { createRoot(document.getElementById("root")!).render( diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts index 5c494e91..3dbb36c3 100644 --- a/src/types/guacamole-common-js.d.ts +++ b/src/types/guacamole-common-js.d.ts @@ -41,6 +41,26 @@ declare module "guacamole-common-js" { constructor(url: string); } + class SessionRecording { + constructor(source: Blob | Tunnel, refreshInterval?: number); + onload: (() => void) | null; + onerror: ((message: string) => void) | null; + onprogress: ((duration: number, parsedSize: number) => void) | null; + onplay: (() => void) | null; + onpause: (() => void) | null; + onseek: + | ((position: number, current: number, total: number) => void) + | null; + getDisplay(): Display; + getPosition(): number; + getDuration(): number; + isPlaying(): boolean; + play(): void; + pause(): void; + seek(position: number, callback?: () => void): void; + abort(): void; + } + class Mouse { constructor(element: HTMLElement); onmousedown: ((state: Mouse.State) => void) | null; diff --git a/src/types/index.ts b/src/types/index.ts index 67f6a2cf..3b2b9a03 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,9 +70,23 @@ export type GuacamoleAuthType = "password" | "credential"; export interface ProxmoxConfig { defaultCredentialId: number | null; + defaultAuthType?: string; windowsPatterns: string; dockerPatterns: string; preferredPrefixes: string; + autoSyncEnabled?: boolean; + syncIntervalMinutes?: number; + markMissingGuests?: boolean; + lastSyncAt?: string; + lastSyncStatus?: "success" | "error"; + lastSyncError?: string | null; + lastSyncResult?: { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; + }; } export interface HostFeatureFlags { @@ -109,7 +123,8 @@ export interface Host { | "none" | "opkssh" | "tailscale" - | "agent"; + | "agent" + | "vault"; useWarpgate?: boolean; password?: string; key?: string; @@ -123,6 +138,8 @@ export interface Host { autostartKeyPassword?: string; credentialId?: number; + vaultProfileId?: number | null; + vaultProfile?: { id?: number | null }; overrideCredentialUsername?: boolean; userId?: string; enableTerminal: boolean; @@ -200,8 +217,9 @@ export interface Host { hasKeyPassword?: boolean; isShared?: boolean; - permissionLevel?: "view"; + permissionLevel?: "connect" | "view" | "edit" | "manage"; sharedExpiresAt?: string; + ownerUsername?: string; } export interface JumpHostData { @@ -239,7 +257,7 @@ export interface HostData { | "agent"; useWarpgate?: boolean; password?: string; - key?: File | null; + key?: File | string | null; keyPassword?: string; keyType?: string; sudoPassword?: string; @@ -952,6 +970,8 @@ export type PartialExcept = Partial & Pick; export interface AuthenticatedRequest extends Request { userId: string; sessionId?: string; + apiKeyId?: string; + actingAdminUserId?: string; user?: { id: string; username: string; diff --git a/src/types/proxmox.ts b/src/types/proxmox.ts index e2d3549b..e7507e32 100644 --- a/src/types/proxmox.ts +++ b/src/types/proxmox.ts @@ -14,3 +14,11 @@ export interface ProxmoxDiscoverResult { credentialId: number | null; defaultCredentialId: number | null; } + +export interface ProxmoxSyncResult { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; +} diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 4d9e14f4..304d4127 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -116,6 +116,19 @@ export type Host = { windowsPatterns: string; dockerPatterns: string; preferredPrefixes: string; + autoSyncEnabled?: boolean; + syncIntervalMinutes?: number; + markMissingGuests?: boolean; + lastSyncAt?: string; + lastSyncStatus?: "success" | "error"; + lastSyncError?: string | null; + lastSyncResult?: { + created: number; + updated: number; + markedMissing: number; + skipped: number; + errors: string[]; + }; } | null; statsConfig?: { @@ -155,8 +168,15 @@ export type Host = { guacamoleConfig?: Record; forceKeyboardInteractive?: boolean; + + isShared?: boolean; + permissionLevel?: SharePermissionLevel; + sharedExpiresAt?: string; + ownerUsername?: string; }; +export type SharePermissionLevel = "connect" | "view" | "edit" | "manage"; + export type Credential = { id: string; name: string; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 0d0e6379..5c905e55 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -6,30 +6,101 @@ import { Separator } from "@/components/separator"; import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; -import { useState, useRef, useCallback, useEffect, createRef } from "react"; +import { + useState, + useRef, + useCallback, + useEffect, + createRef, + lazy, + Suspense, +} from "react"; import { createPortal } from "react-dom"; import { useIsMobile } from "@/hooks/use-mobile"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; -import { CommandPalette } from "@/shell/CommandPalette"; import { AppRail } from "@/sidebar/AppRail"; import type { RailView } from "@/sidebar/AppRail"; -import { HostsPanel } from "@/sidebar/HostsPanel"; -import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel"; -import { SerialPanel } from "@/sidebar/SerialPanel"; -import { SshToolsPanel } from "@/sidebar/SshToolsPanel"; -import { SnippetsPanel } from "@/sidebar/SnippetsPanel"; -import { HistoryPanel } from "@/sidebar/HistoryPanel"; -import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel"; -import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel"; -import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; -import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; -import { AlertsPanel } from "@/sidebar/AlertsPanel"; -import { CredentialsPanel } from "@/sidebar/CredentialsPanel"; -import { TermixIdPanel } from "@/sidebar/TermixIdPanel"; import { SplitView } from "@/shell/SplitView"; import { renderTabContent } from "@/shell/tabUtils"; -import { AlertManager } from "@/dashboard/panels/alerts/AlertManager"; import { TabBar } from "@/shell/TabBar"; + +// Shell surfaces that are not needed for first paint. +const CommandPalette = lazy(() => + import("@/shell/CommandPalette").then((m) => ({ + default: m.CommandPalette, + })), +); +const HostsPanel = lazy(() => + import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })), +); +const QuickConnectPanel = lazy(() => + import("@/sidebar/QuickConnectPanel").then((m) => ({ + default: m.QuickConnectPanel, + })), +); +const SerialPanel = lazy(() => + import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })), +); +const SplitScreenPanel = lazy(() => + import("@/sidebar/SplitScreenPanel").then((m) => ({ + default: m.SplitScreenPanel, + })), +); +const AlertManager = lazy(() => + import("@/dashboard/panels/alerts/AlertManager").then((m) => ({ + default: m.AlertManager, + })), +); + +// Secondary rail panels — load on first open, not with the shell critical path. +const SshToolsPanel = lazy(() => + import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })), +); +const SnippetsPanel = lazy(() => + import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })), +); +const HistoryPanel = lazy(() => + import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })), +); +const SessionLogsPanel = lazy(() => + import("@/sidebar/SessionLogsPanel").then((m) => ({ + default: m.SessionLogsPanel, + })), +); +const UserProfilePanel = lazy(() => + import("@/sidebar/UserProfilePanel").then((m) => ({ + default: m.UserProfilePanel, + })), +); +const AdminSettingsPanel = lazy(() => + import("@/sidebar/AdminSettingsPanel").then((m) => ({ + default: m.AdminSettingsPanel, + })), +); +const AlertsPanel = lazy(() => + import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })), +); +const CredentialsPanel = lazy(() => + import("@/sidebar/CredentialsPanel").then((m) => ({ + default: m.CredentialsPanel, + })), +); +const TermixIdPanel = lazy(() => + import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })), +); +const ConnectionsPanel = lazy(() => + import("@/sidebar/ConnectionsPanel").then((m) => ({ + default: m.ConnectionsPanel, + })), +); + +function SidebarPanelFallback() { + return ( +
+
+
+ ); +} import type { Tab, TabType, @@ -51,18 +122,22 @@ import { addOpenTab, deleteOpenTab, patchOpenTab, + createSSHHost, getActiveSessions, getUserPreferences, + dismissDonationModal, type UserPreferences, type OpenTabRecord, } from "@/main-axios"; +import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; import { ServerStatusProvider } from "@/lib/ServerStatusContext"; -import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel"; import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; +import { changeAppLanguage } from "@/i18n/i18n"; +import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; function buildHostTree( hosts: SSHHostWithStatus[], @@ -157,6 +232,7 @@ export function AppShell({ const [allHosts, setAllHosts] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [userId, setUserId] = useState(null); + const [showDonationModal, setShowDonationModal] = useState(false); const [backgroundTabRecords, setBackgroundTabRecords] = useState< OpenTabRecord[] >([]); @@ -202,10 +278,16 @@ export function AppShell({ .then((info) => { setIsAdmin(info.is_admin); setUserId(info.userId); + setShowDonationModal(!!info.show_donation_modal); }) .catch(() => setIsAdmin(false)); }, []); + const handleDismissDonationModal = useCallback(() => { + setShowDonationModal(false); + dismissDonationModal().catch(() => {}); + }, []); + const toggleAppFullscreen = useCallback(async () => { try { if (document.fullscreenElement) { @@ -618,8 +700,7 @@ export function AppShell({ applyAccentColor(prefs.accentColor); } if (prefs.language && prefs.language !== i18n.language) { - localStorage.setItem("i18nextLng", prefs.language); - void i18n.changeLanguage(prefs.language); + void changeAppLanguage(prefs.language); } if ( prefs.commandAutocomplete !== null && @@ -738,8 +819,17 @@ export function AppShell({ }, [loadHosts]); useEffect(() => { - window.addEventListener("termix:hosts-changed", loadHosts); - return () => window.removeEventListener("termix:hosts-changed", loadHosts); + const onHostsChanged = () => { + void loadHosts(); + }; + window.addEventListener("termix:hosts-changed", onHostsChanged); + window.addEventListener("ssh-hosts:changed", onHostsChanged); + window.addEventListener("hosts:refresh", onHostsChanged); + return () => { + window.removeEventListener("termix:hosts-changed", onHostsChanged); + window.removeEventListener("ssh-hosts:changed", onHostsChanged); + window.removeEventListener("hosts:refresh", onHostsChanged); + }; }, [loadHosts]); // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) @@ -1013,6 +1103,21 @@ export function AppShell({ openTab(host, type); } + const saveQuickConnectHost = useCallback( + async (tab: Tab, host: Host) => { + try { + const savedHost = await createSSHHost(quickConnectHostToPayload(host)); + await patchOpenTab(tab.instanceId, { hostId: savedHost.id }); + await loadHosts(); + toast.success(t("hosts.hostCreated")); + } catch (error) { + toast.error(t("hosts.failedToSave")); + throw error; + } + }, + [loadHosts, t], + ); + function openSerialTab(config: SerialConfig) { const pseudoHost: Host = { id: `serial-${Date.now()}`, @@ -1437,169 +1542,182 @@ export function AppShell({ // Sidebar panel content — shared between desktop inline sidebar and mobile sheet const sidebarPanelContent = ( -
-
- { - connectHost(host, type); - if (isMobile) setSidebarOpen(false); - }} - onEditHost={editHostInManager} - hostTree={realHostTree ?? undefined} - loading={hostsLoading} - onEditingChange={setSidebarEditing} - active={railView === "hosts"} - /> -
- -
- -
- - {railView === "termix-id" && ( -
- -
- )} - - {railView === "serial" && ( - { - openSerialTab(config); - if (isMobile) setSidebarOpen(false); - }} - /> - )} - - {railView === "quick-connect" && ( - { - openTab(host, type); - if (isMobile) setSidebarOpen(false); - }} - /> - )} - - {railView === "ssh-tools" && ( -
- -
- )} - - {railView === "snippets" && ( -
- -
- )} - - {railView === "history" && ( -
- -
- )} - - {railView === "split-screen" && ( -
- -
- )} - - {railView === "connections" && ( -
- { - setActiveTabId(tabId); + }> +
+
+ { + connectHost(host, type); if (isMobile) setSidebarOpen(false); }} - onCloseTab={closeTab} - onReopenTab={(record, restoredSessionId) => { - const host = record.hostId - ? allHosts.find((h) => h.id === String(record.hostId)) - : undefined; - const hostlessTypes: TabType[] = ["tunnel"]; - if (!host && !hostlessTypes.includes(record.tabType as TabType)) - return; - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== record.id), - ); - if (host) { - const effectiveSessionId = - restoredSessionId ?? record.backendSessionId ?? null; - openTab(host, record.tabType as TabType, { - instanceId: record.id, - restoredSessionId: effectiveSessionId, - savedLabel: record.label, - }); - } else { - openSingletonTab(record.tabType as TabType); + onEditHost={editHostInManager} + hostTree={realHostTree ?? undefined} + loading={hostsLoading} + onEditingChange={setSidebarEditing} + active={railView === "hosts"} + /> +
+ +
+ +
+ + {railView === "termix-id" && ( +
+ +
+ )} + + {railView === "serial" && ( + { + openSerialTab(config); + if (isMobile) setSidebarOpen(false); + }} + /> + )} + + {railView === "quick-connect" && ( + { + openTab(host, type); + if (isMobile) setSidebarOpen(false); + }} + /> + )} + + {railView === "ssh-tools" && ( +
+ +
+ )} + + {railView === "snippets" && ( +
+ +
+ )} + + {railView === "history" && ( +
+ +
+ )} + + {railView === "split-screen" && ( +
+ +
+ )} + + {railView === "connections" && ( +
+ { + setActiveTabId(tabId); + if (isMobile) setSidebarOpen(false); + }} + onCloseTab={closeTab} + onReopenTab={(record, restoredSessionId) => { + const host = record.hostId + ? allHosts.find((h) => h.id === String(record.hostId)) + : undefined; + const hostlessTypes: TabType[] = ["tunnel"]; + if (!host && !hostlessTypes.includes(record.tabType as TabType)) + return; + setBackgroundTabRecords((prev) => + prev.filter((r) => r.id !== record.id), + ); + if (host) { + const effectiveSessionId = + restoredSessionId ?? record.backendSessionId ?? null; + openTab(host, record.tabType as TabType, { + instanceId: record.id, + restoredSessionId: effectiveSessionId, + savedLabel: record.label, + }); + } else { + openSingletonTab(record.tabType as TabType); + } + if (isMobile) setSidebarOpen(false); + }} + onForgetBackground={(recordId) => { + setBackgroundTabRecords((prev) => + prev.filter((r) => r.id !== recordId), + ); + }} + onRenameTab={renameTab} + onReorderTabs={setTabs} + /> +
+ )} + + {railView === "session-logs" && ( +
+ +
+ )} + + {railView === "user-profile" && ( +
+ + setUserPrefs((current) => ({ ...current, ...updates })) } - if (isMobile) setSidebarOpen(false); - }} - onForgetBackground={(recordId) => { - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== recordId), - ); - }} - onRenameTab={renameTab} - onReorderTabs={setTabs} - /> -
- )} + /> +
+ )} - {railView === "session-logs" && ( -
- -
- )} + {railView === "admin-settings" && isAdmin && ( +
+ { + connectHost(host); + if (isMobile) setSidebarOpen(false); + }} + /> +
+ )} - {railView === "user-profile" && ( -
- -
- )} - - {railView === "admin-settings" && isAdmin && ( -
- -
- )} - - {railView === "alerts" && ( -
- -
- )} -
+ {railView === "alerts" && ( +
+ +
+ )} +
+ ); // Sidebar header — shared @@ -1652,7 +1770,7 @@ export function AppShell({ {/* Desktop: inline resizable sidebar */} {!isMobile && (
{sidebarHeader} @@ -1713,6 +1831,10 @@ export function AppShell({ onAddToSplit={addTabToSplit} onRemoveFromSplit={removeTabFromSplit} onRenameTab={renameTab} + onOpenFileManager={(tabId) => { + const targetTab = tabs.find((t) => t.id === tabId); + if (targetTab?.host) openTab(targetTab.host, "files"); + }} isAppFullscreen={isAppFullscreen} onToggleAppFullscreen={toggleAppFullscreen} /> @@ -1789,6 +1911,7 @@ export function AppShell({ initialFilePath: path, }), renameTab, + saveQuickConnectHost, ), tabNode, tab.id, @@ -1808,35 +1931,45 @@ export function AppShell({
- { - if ( - [ - "dashboard", - "host-manager", - "user-profile", - "admin-settings", - ].includes(type) - ) { - openSingletonTab(type, pendingEvent); - } else if (type === "tmux_monitor") { - // --- tmux-monitor --- singleton tab, optionally preselecting a host - openSingletonTab( - type, - undefined, - label ? allHosts.find((h) => h.name === label) : undefined, - ); - } else if (label) { - const host = allHosts.find((h) => h.name === label); - if (host) openTab(host, type); - } - }} - /> + {commandPaletteOpen && ( + + { + if ( + [ + "dashboard", + "host-manager", + "user-profile", + "admin-settings", + ].includes(type) + ) { + openSingletonTab(type, pendingEvent); + } else if (type === "tmux_monitor") { + // --- tmux-monitor --- singleton tab, optionally preselecting a host + openSingletonTab( + type, + undefined, + label ? allHosts.find((h) => h.name === label) : undefined, + ); + } else if (label) { + const host = allHosts.find((h) => h.name === label); + if (host) openTab(host, type); + } + }} + /> + + )} - + + + + ); } diff --git a/src/ui/api/admin-user-data-api.ts b/src/ui/api/admin-user-data-api.ts new file mode 100644 index 00000000..ecac34ea --- /dev/null +++ b/src/ui/api/admin-user-data-api.ts @@ -0,0 +1,241 @@ +import { authApi, handleApiError, sshHostApi } from "@/main-axios"; +import type { SSHHost, SSHHostData } from "@/types/index"; + +// ADMIN USER DATA MANAGEMENT +// ============================================================================ +// Wrappers over the regular data-plane endpoints that act on another user's +// data via the X-Admin-Target-User header (admin only, audited server-side). +// They intentionally bypass the host request caches: the data belongs to the +// target user, not the signed-in admin. + +const ADMIN_TARGET_USER_HEADER = "X-Admin-Target-User"; + +function adminHeaders(targetUserId: string): Record { + return { [ADMIN_TARGET_USER_HEADER]: targetUserId }; +} + +export async function adminGetUserHosts( + targetUserId: string, +): Promise { + try { + const response = await sshHostApi.get("/db/host", { + headers: adminHeaders(targetUserId), + }); + return Array.isArray(response.data) ? response.data : []; + } catch (error) { + throw handleApiError(error, "fetch user's hosts"); + } +} + +export async function adminCreateUserHost( + targetUserId: string, + hostData: SSHHostData, +): Promise { + try { + if (hostData.authType === "key" && hostData.key instanceof File) { + const formData = new FormData(); + formData.append("key", hostData.key); + const dataWithoutFile = { ...hostData, key: undefined }; + formData.append("data", JSON.stringify(dataWithoutFile)); + const response = await sshHostApi.post("/db/host", formData, { + headers: { + "Content-Type": "multipart/form-data", + ...adminHeaders(targetUserId), + }, + }); + return response.data; + } + const response = await sshHostApi.post("/db/host", hostData, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "create host for user"); + } +} + +export async function adminUpdateUserHost( + targetUserId: string, + hostId: number, + hostData: SSHHostData, +): Promise { + try { + if (hostData.authType === "key" && hostData.key instanceof File) { + const formData = new FormData(); + formData.append("key", hostData.key); + const dataWithoutFile = { ...hostData, key: undefined }; + formData.append("data", JSON.stringify(dataWithoutFile)); + const response = await sshHostApi.put(`/db/host/${hostId}`, formData, { + headers: { + "Content-Type": "multipart/form-data", + ...adminHeaders(targetUserId), + }, + }); + return response.data; + } + const response = await sshHostApi.put(`/db/host/${hostId}`, hostData, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "update user's host"); + } +} + +export async function adminDeleteUserHost( + targetUserId: string, + hostId: number, +): Promise> { + try { + const response = await sshHostApi.delete(`/db/host/${hostId}`, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "delete user's host"); + } +} + +export async function adminGetHostPassword( + targetUserId: string, + hostId: number, + field: "password" | "sudoPassword" | "vncPassword" = "password", +): Promise { + try { + const response = await sshHostApi.get( + `/db/host/${hostId}/password?field=${field}`, + { headers: adminHeaders(targetUserId) }, + ); + return response.data?.value || null; + } catch { + return null; + } +} + +export async function adminGetUserCredentials( + targetUserId: string, +): Promise[] | Record> { + try { + const response = await authApi.get("/credentials", { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch user's credentials"); + } +} + +export async function adminGetUserCredentialDetails( + targetUserId: string, + credentialId: number, +): Promise> { + try { + const response = await authApi.get(`/credentials/${credentialId}`, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch user's credential details"); + } +} + +export async function adminCreateUserCredential( + targetUserId: string, + credentialData: Record, +): Promise> { + try { + const response = await authApi.post("/credentials", credentialData, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "create credential for user"); + } +} + +export async function adminUpdateUserCredential( + targetUserId: string, + credentialId: number, + credentialData: Record, +): Promise> { + try { + const response = await authApi.put( + `/credentials/${credentialId}`, + credentialData, + { headers: adminHeaders(targetUserId) }, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "update user's credential"); + } +} + +export async function adminDeleteUserCredential( + targetUserId: string, + credentialId: number, +): Promise> { + try { + const response = await authApi.delete(`/credentials/${credentialId}`, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "delete user's credential"); + } +} + +export async function adminGetUserSnippets( + targetUserId: string, +): Promise> { + try { + const response = await authApi.get("/snippets", { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch user's snippets"); + } +} + +export async function adminCreateUserSnippet( + targetUserId: string, + snippetData: Record, +): Promise> { + try { + const response = await authApi.post("/snippets", snippetData, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "create snippet for user"); + } +} + +export async function adminUpdateUserSnippet( + targetUserId: string, + snippetId: number, + snippetData: Record, +): Promise> { + try { + const response = await authApi.put(`/snippets/${snippetId}`, snippetData, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "update user's snippet"); + } +} + +export async function adminDeleteUserSnippet( + targetUserId: string, + snippetId: number, +): Promise> { + try { + const response = await authApi.delete(`/snippets/${snippetId}`, { + headers: adminHeaders(targetUserId), + }); + return response.data; + } catch (error) { + throw handleApiError(error, "delete user's snippet"); + } +} diff --git a/src/ui/api/alerts-api.ts b/src/ui/api/alerts-api.ts index 82cb342c..2b1ef500 100644 --- a/src/ui/api/alerts-api.ts +++ b/src/ui/api/alerts-api.ts @@ -40,6 +40,28 @@ export interface AlertFiring { ruleName?: string; } +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function nullableNumberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function boolValue(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +function severityValue(value: unknown): AlertFiring["severity"] { + return value === "info" || value === "critical" || value === "warning" + ? value + : "warning"; +} + export async function getNotificationChannels(): Promise< NotificationChannel[] > { @@ -91,6 +113,23 @@ function mapRule(r: Record): AlertRule { }; } +export function mapAlertFiring(r: Record): AlertFiring { + return { + id: numberValue(r.id), + userId: stringValue(r.userId ?? r.user_id), + ruleId: numberValue(r.ruleId ?? r.rule_id), + hostId: numberValue(r.hostId ?? r.host_id), + hostName: stringValue(r.hostName ?? r.host_name), + firedAt: stringValue(r.firedAt ?? r.fired_at, new Date(0).toISOString()), + resolvedAt: stringValue(r.resolvedAt ?? r.resolved_at) || null, + value: nullableNumberValue(r.value), + message: stringValue(r.message), + severity: severityValue(r.severity), + acknowledged: boolValue(r.acknowledged), + ruleName: stringValue(r.ruleName ?? r.rule_name) || undefined, + }; +} + export async function getAlertRules(): Promise { const res = await rbacApi.get("/alert-rules"); return (res.data as Record[]).map(mapRule); @@ -121,7 +160,13 @@ export async function getAlertFirings(opts?: { acknowledged?: boolean; }): Promise { const res = await rbacApi.get("/alert-firings", { params: opts }); - return (res.data as { firings: AlertFiring[] }).firings ?? res.data; + const data = res.data as { firings?: unknown } | unknown[]; + const rows = Array.isArray(data) + ? data + : Array.isArray(data.firings) + ? data.firings + : []; + return rows.map((row) => mapAlertFiring(row as Record)); } export async function acknowledgeAlertFiring(id: number): Promise { diff --git a/src/ui/api/audit-log-api.ts b/src/ui/api/audit-log-api.ts index a9d3b88d..a22c48d1 100644 --- a/src/ui/api/audit-log-api.ts +++ b/src/ui/api/audit-log-api.ts @@ -34,6 +34,40 @@ export interface AuditLogResponse { totalPages: number; } +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function nullableStringValue(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function boolValue(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +export function mapAuditLog(row: Record): AuditLog { + return { + id: numberValue(row.id), + userId: stringValue(row.userId ?? row.user_id), + username: stringValue(row.username), + action: stringValue(row.action, "unknown"), + resourceType: stringValue(row.resourceType ?? row.resource_type, "unknown"), + resourceId: nullableStringValue(row.resourceId ?? row.resource_id), + resourceName: nullableStringValue(row.resourceName ?? row.resource_name), + details: nullableStringValue(row.details), + ipAddress: nullableStringValue(row.ipAddress ?? row.ip_address), + userAgent: nullableStringValue(row.userAgent ?? row.user_agent), + success: boolValue(row.success), + errorMessage: nullableStringValue(row.errorMessage ?? row.error_message), + timestamp: stringValue(row.timestamp, new Date(0).toISOString()), + }; +} + export async function getAuditLogs( filters: AuditLogFilters = {}, ): Promise { @@ -50,7 +84,14 @@ export async function getAuditLogs( if (filters.endDate) params.set("endDate", filters.endDate); const response = await authApi.get(`/audit-logs?${params.toString()}`); - return response.data; + const data = response.data as Partial; + const rows = Array.isArray(data.logs) ? data.logs : []; + return { + logs: rows.map((row) => mapAuditLog(row as Record)), + total: numberValue(data.total), + page: numberValue(data.page, filters.page ?? 1), + totalPages: numberValue(data.totalPages, 1), + }; } catch (error) { handleApiError(error, "fetch audit logs"); } diff --git a/src/ui/api/host-metrics-status-api.ts b/src/ui/api/host-metrics-status-api.ts index 53b31ac4..0a6eebe7 100644 --- a/src/ui/api/host-metrics-status-api.ts +++ b/src/ui/api/host-metrics-status-api.ts @@ -1,6 +1,7 @@ import axios, { type AxiosRequestConfig } from "axios"; import { handleApiError, statsApi } from "@/main-axios"; import type { ServerMetrics, ServerStatus } from "@/main-axios"; +import { getCachedServerStatuses } from "@/lib/hosts-request-cache"; type ApiConnectionLog = { type: "info" | "success" | "warning" | "error"; @@ -73,34 +74,37 @@ function isTransientStatusError(error: unknown): boolean { export async function getAllServerStatuses(): Promise< Record > { - let lastError: unknown = null; + return getCachedServerStatuses(async () => { + let lastError: unknown = null; - for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { - const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; - const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1; + for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { + const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; + const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1; - try { - const response = await statsApi.get("/status", { - timeout: timeoutMs, - // Silence per-attempt interceptor logging & health-monitor side - // effects on all attempts except the final one, so background - // blips don't look like real outages. - __silentRetry: !isFinalAttempt, - } as AxiosRequestConfig & { __silentRetry?: boolean }); - return response.data || {}; - } catch (error) { - lastError = error; - if (!isTransientStatusError(error)) { - break; + try { + const response = await statsApi.get("/status", { + timeout: timeoutMs, + // Silence per-attempt interceptor logging & health-monitor side + // effects on all attempts except the final one, so background + // blips don't look like real outages. + __silentRetry: !isFinalAttempt, + } as AxiosRequestConfig & { __silentRetry?: boolean }); + return response.data || {}; + } catch (error) { + lastError = error; + if (!isTransientStatusError(error)) { + break; + } + if (pauseAfterMs === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, pauseAfterMs)); } - if (pauseAfterMs === null) { - break; - } - await new Promise((resolve) => setTimeout(resolve, pauseAfterMs)); } - } - handleApiError(lastError, "fetch server statuses"); + handleApiError(lastError, "fetch server statuses"); + return {}; + }); } export async function getServerStatusById(id: number): Promise { diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index c64f2e06..e9eb5a7c 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -1,4 +1,5 @@ import { authApi } from "@/main-axios"; +import { createTtlRequestCache } from "@/lib/ttl-request-cache"; // OPEN TABS API // ============================================================================ @@ -42,6 +43,8 @@ export interface ActiveSessionInfo { createdAt: number; } +const activeSessionsCache = createTtlRequestCache(2_000); + export async function getOpenTabs(): Promise { const response = await authApi.get("/open-tabs"); return response.data; @@ -69,8 +72,10 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise { } export async function getActiveSessions(): Promise { - const response = await authApi.get("/open-tabs/active-sessions"); - return response.data; + return activeSessionsCache.get(async () => { + const response = await authApi.get("/open-tabs/active-sessions"); + return Array.isArray(response.data) ? response.data : []; + }); } // ============================================================================ diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 628910bb..63f2553a 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -28,6 +28,7 @@ export async function updateRole( roleData: { displayName?: string; description?: string | null; + permissions?: string[]; }, ): Promise<{ role: Role }> { try { @@ -88,16 +89,30 @@ export async function removeRoleFromUser( } } +export type SharePermissionLevel = "connect" | "view" | "edit" | "manage"; + +export interface ShareTarget { + type: "user" | "role"; + id: string | number; +} + export async function shareHost( hostId: number, shareData: { - targetType: "user" | "role"; - targetUserId?: string; - targetRoleId?: number; - permissionLevel: "view"; + targets: ShareTarget[]; + permissionLevel: SharePermissionLevel; durationHours?: number; }, -): Promise<{ success: boolean }> { +): Promise<{ + success: boolean; + expiresAt: string | null; + results: Array<{ + type: "user" | "role"; + id: string | number; + accessId: number; + created: boolean; + }>; +}> { try { const response = await rbacApi.post( `/rbac/host/${hostId}/share`, @@ -109,9 +124,28 @@ export async function shareHost( } } +export async function updateHostAccess( + hostId: number, + accessId: number, + update: { + permissionLevel?: SharePermissionLevel; + durationHours?: number | null; + }, +): Promise<{ success: boolean; expiresAt: string | null }> { + try { + const response = await rbacApi.patch( + `/rbac/host/${hostId}/access/${accessId}`, + update, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "update host access"); + } +} + export async function getHostAccess( hostId: number, -): Promise<{ accessList: AccessRecord[] }> { +): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> { try { const response = await rbacApi.get(`/rbac/host/${hostId}/access`); return response.data; @@ -120,6 +154,45 @@ export async function getHostAccess( } } +export interface PermissionCatalogEntry { + group: string; + permissions: string[]; +} + +export async function getPermissionsCatalog(): Promise<{ + catalog: PermissionCatalogEntry[]; +}> { + try { + const response = await rbacApi.get("/rbac/permissions/catalog"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch permissions catalog"); + } +} + +export async function getSharedHosts(): Promise<{ + sharedHosts: Array<{ + id: number; + name: string | null; + ip: string; + port: number; + username: string; + folder: string | null; + tags: string | null; + permissionLevel: SharePermissionLevel; + expiresAt: string | null; + grantedBy: string; + ownerUsername: string; + }>; +}> { + try { + const response = await rbacApi.get("/rbac/shared-hosts"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch shared hosts"); + } +} + export async function revokeHostAccess( hostId: number, accessId: number, diff --git a/src/ui/api/session-log-api.ts b/src/ui/api/session-log-api.ts index c3ffa911..25e696a3 100644 --- a/src/ui/api/session-log-api.ts +++ b/src/ui/api/session-log-api.ts @@ -11,6 +11,9 @@ export type SessionLogRecord = { hostName: string | null; hostIp: string | null; sizeBytes: number | null; + protocol: "ssh" | "rdp" | "vnc" | "telnet"; + format: "text" | "asciicast" | "guacamole"; + username: string | null; }; export async function getSessionLogs(): Promise { @@ -22,6 +25,36 @@ export async function getSessionLogs(): Promise { } } +export async function getSessionLogBlob(id: number): Promise { + try { + const response = await authApi.get(`/session_logs/${id}/content`, { + responseType: "blob", + }); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch session recording"); + } +} + +export async function getSessionRecordingRetention(): Promise { + try { + const response = await authApi.get("/session_logs/retention"); + return response.data.retentionDays; + } catch (error) { + throw handleApiError(error, "fetch session recording retention"); + } +} + +export async function setSessionRecordingRetention( + retentionDays: number, +): Promise { + try { + await authApi.put("/session_logs/retention", { retentionDays }); + } catch (error) { + throw handleApiError(error, "update session recording retention"); + } +} + export async function getSessionLogContent(id: number): Promise { try { const response = await authApi.get(`/session_logs/${id}/content`, { diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index bd3c17bd..082f4c96 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -410,7 +410,7 @@ export async function uploadSSHFile( form.append("totalSize", String(file.size)); form.append("chunk", chunkBlob, fileName); - const response = await fileManagerApi.post( + const response = await fileManagerApi.postForm( "/ssh/uploadFileChunk", form, { timeout: 0 }, diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 9b1c574a..294a65dc 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -7,17 +7,39 @@ import { } from "@/main-axios"; import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index"; import type { ServerStatus, SSHHostWithStatus } from "@/main-axios"; -import type { ProxmoxDiscoverResult } from "@/types/proxmox"; +import type { ProxmoxDiscoverResult, ProxmoxSyncResult } from "@/types/proxmox"; +import { + getCachedSSHHosts, + invalidateHostsAndStatusCaches, +} from "@/lib/hosts-request-cache"; // SSH HOST MANAGEMENT // ============================================================================ -export async function getSSHHosts(): Promise { +export type GetSSHHostsOptions = { + /** When false, skip the status service call (host config only). Default true. */ + includeStatus?: boolean; +}; + +async function loadSSHHostsFromApi(): Promise { + const hostsResponse = await sshHostApi.get("/db/host"); + return Array.isArray(hostsResponse.data) ? hostsResponse.data : []; +} + +export async function getSSHHosts( + options: GetSSHHostsOptions = {}, +): Promise { + const includeStatus = options.includeStatus !== false; + try { - const hostsResponse = await sshHostApi.get("/db/host"); - const hosts: SSHHost[] = Array.isArray(hostsResponse.data) - ? hostsResponse.data - : []; + const hosts = await getCachedSSHHosts(loadSSHHostsFromApi); + + if (!includeStatus) { + return hosts.map((host) => ({ + ...host, + status: "unknown", + })); + } let statuses: Record = {}; try { @@ -45,9 +67,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise { const response = await sshHostApi.post("/db/host", formData, { headers: { "Content-Type": "multipart/form-data" }, }); + invalidateHostsAndStatusCaches(); return response.data; } const response = await sshHostApi.post("/db/host", hostData); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { throw handleApiError(error, "create SSH host"); @@ -67,9 +91,11 @@ export async function updateSSHHost( const response = await sshHostApi.put(`/db/host/${hostId}`, formData, { headers: { "Content-Type": "multipart/form-data" }, }); + invalidateHostsAndStatusCaches(); return response.data; } const response = await sshHostApi.put(`/db/host/${hostId}`, hostData); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { throw handleApiError(error, "update SSH host"); @@ -103,6 +129,7 @@ export async function bulkImportSSHHosts( overwrite, ...(credentials ? { credentials } : {}), }); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "bulk import SSH hosts"); @@ -125,6 +152,7 @@ export async function importSSHConfigHosts( content, overwrite, }); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "import SSH config hosts"); @@ -146,6 +174,21 @@ export async function discoverProxmoxGuests( } } +export async function syncProxmoxGuests( + hostId: number, +): Promise { + try { + const response = await authApi.post( + "/proxmox/sync", + { hostId }, + { timeout: 120000 }, + ); + return response.data; + } catch (error) { + handleApiError(error, "sync Proxmox guests"); + } +} + export async function bulkUpdateSSHHosts( hostIds: number[], updates: Record, @@ -155,6 +198,7 @@ export async function bulkUpdateSSHHosts( hostIds, updates, }); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "bulk update SSH hosts"); @@ -166,6 +210,7 @@ export async function deleteSSHHost( ): Promise> { try { const response = await sshHostApi.delete(`/db/host/${hostId}`); + invalidateHostsAndStatusCaches(); return response.data; } catch (error) { handleApiError(error, "delete SSH host"); diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index 1fe4c6e3..a3ebdb90 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -161,6 +161,48 @@ export async function deleteAccount( } } +// Raw axios errors propagate here so callers can detect the 409 +// DATA_WIPE_REQUIRED code and re-submit with confirmDataWipe. +export async function adminResetUserPassword( + userId: string, + newPassword: string, + confirmDataWipe = false, +): Promise<{ message: string; dataWiped?: boolean }> { + const response = await authApi.post("/users/admin/reset-password", { + userId, + newPassword, + confirmDataWipe, + }); + return response.data; +} + +export async function adminDisableUserTotp( + userId: string, +): Promise<{ message: string }> { + try { + const response = await authApi.post("/users/admin/totp/disable", { + userId, + }); + return response.data; + } catch (error) { + handleApiError(error, "disable user TOTP"); + } +} + +export async function adminExportUserData( + userId: string, +): Promise> { + try { + const response = await authApi.get( + `/users/admin/export/${encodeURIComponent(userId)}`, + { timeout: 120000 }, + ); + return response.data; + } catch (error) { + handleApiError(error, "export user data"); + } +} + export async function updateRegistrationAllowed( allowed: boolean, ): Promise> { diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index db4bad3d..b34a4c48 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -41,7 +41,7 @@ import type { SSOProviderPublic } from "@/types/index"; import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { Checkbox } from "@/components/checkbox"; -import i18n from "@/i18n/i18n"; +import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { removeSilentSigninFromSearch, shouldTriggerSilentSignin, @@ -238,14 +238,14 @@ export function Auth({ onLogin }: AuthProps) { const [confirmNewPassword, setConfirmNewPassword] = useState(""); const [resetTempToken, setResetTempToken] = useState(""); - const [language, setLanguage] = useState( - () => localStorage.getItem("i18nextLng") ?? "en", + const [language, setLanguage] = useState(() => + normalizeLanguageCode(localStorage.getItem("i18nextLng")), ); function handleLanguageChange(code: string) { - setLanguage(code); - localStorage.setItem("i18nextLng", code); - i18n.changeLanguage(code); + void changeAppLanguage(code) + .then((language) => setLanguage(language)) + .catch(() => {}); } const [registrationAllowed, setRegistrationAllowed] = useState(true); diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index bd36ecf7..4efd8497 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -511,7 +511,26 @@ export function Auth({ } try { - await completePasswordReset(localUsername, tempToken, newPassword); + try { + await completePasswordReset(localUsername, tempToken, newPassword); + } catch (err: unknown) { + const error = err as { + response?: { data?: { code?: string } }; + }; + if (error?.response?.data?.code !== "DATA_WIPE_REQUIRED") { + throw err; + } + if (!window.confirm(t("auth.confirmResetDataWipe"))) { + setResetLoading(false); + return; + } + await completePasswordReset( + localUsername, + tempToken, + newPassword, + true, + ); + } setResetStep("initiate"); setResetCode(""); diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index bbbc7a18..7259504a 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -138,6 +138,18 @@ export function ProxmoxDiscoverDialog({ enableDocker: g.enableDocker, connectionType: g.connectionType, tags: ["proxmox", g.type, g.node], + proxmoxConfig: { + source: { + source: "proxmox", + sourceHostId: Number(effectiveHostId), + node: g.node, + vmid: g.vmid, + type: g.type, + lastSeenAt: new Date().toISOString(), + lastStatus: g.status, + missingSince: null, + }, + }, })); const result = await bulkImportSSHHosts(toImport, false); diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 80ec56c5..50154591 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -531,6 +531,10 @@ function HostStatusCard({ ); } +function isStatusCheckEnabled(host: Host): boolean { + return host.statsConfig?.statusCheckEnabled !== false; +} + function RecentActivityCard({ activity, hosts, @@ -797,6 +801,7 @@ function CardItem({ onAddServiceLink, onDeleteServiceLink, statusLoading, + isVisible = true, }: { slot: CardSlot; editMode: boolean; @@ -826,6 +831,7 @@ function CardItem({ onAddServiceLink: (label: string, url: string) => Promise; onDeleteServiceLink: (id: number) => Promise; statusLoading?: boolean; + isVisible?: boolean; }) { const cardRef = useRef(null); @@ -922,6 +928,7 @@ function CardItem({ {slot.id === "network_graph" && ( onOpenSingletonTab("network_graph")} /> )} @@ -1051,6 +1058,7 @@ type PanelColumnProps = { onAddServiceLink: (label: string, url: string) => Promise; onDeleteServiceLink: (id: number) => Promise; statusLoading: boolean; + isVisible?: boolean; }; function PanelColumn({ @@ -1082,6 +1090,7 @@ function PanelColumn({ onAddServiceLink, onDeleteServiceLink, statusLoading, + isVisible = true, }: PanelColumnProps) { const { t } = useTranslation(); const sorted = [...slots].sort((a, b) => a.order - b.order); @@ -1138,6 +1147,7 @@ function PanelColumn({ onAddServiceLink={onAddServiceLink} onDeleteServiceLink={onDeleteServiceLink} statusLoading={statusLoading} + isVisible={isVisible} /> ))} @@ -1188,9 +1198,12 @@ function ColumnDivider({ export function DashboardTab({ onOpenSingletonTab, onOpenTab, + isVisible = true, }: { onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; + /** When false, pause dashboard metrics refresh while the tab stays mounted. */ + isVisible?: boolean; }) { const { t, i18n } = useTranslation(); const { initialLoadComplete } = useServerStatus(); @@ -1283,6 +1296,7 @@ export function DashboardTab({ Map >(new Map()); const viewerSessionsRef = useRef>(new Map()); + const statusCheckHosts = hosts.filter(isStatusCheckEnabled); const fetchMetrics = useCallback(async (hostList: Host[]) => { let statuses: Record = {}; @@ -1342,8 +1356,11 @@ export function DashboardTab({ const load = async () => { const raw = await getSSHHosts().catch(() => []); const mapped = raw.map(sshHostToHost); + const statusHosts = mapped.filter(isStatusCheckEnabled); if (mounted) setHosts(mapped); - fetchMetrics(mapped).catch(() => {}); + if (isVisible) { + fetchMetrics(statusHosts).catch(() => {}); + } }; load(); @@ -1393,28 +1410,37 @@ export function DashboardTab({ }) .catch(() => {}); + if (!isVisible) { + return () => { + mounted = false; + }; + } + const metricsInterval = setInterval(async () => { + if (document.visibilityState === "hidden") return; const raw = await getSSHHosts().catch(() => []); const mapped = raw.map(sshHostToHost); + const statusHosts = mapped.filter(isStatusCheckEnabled); if (mounted) setHosts(mapped); - fetchMetrics(mapped).catch(() => {}); + fetchMetrics(statusHosts).catch(() => {}); }, 30000); return () => { mounted = false; clearInterval(metricsInterval); }; - }, [fetchMetrics]); + }, [fetchMetrics, isVisible]); useEffect(() => { - if (viewerSessionsRef.current.size === 0) return; + if (!isVisible || viewerSessionsRef.current.size === 0) return; const heartbeat = setInterval(async () => { + if (document.visibilityState === "hidden") return; for (const [, sessionId] of viewerSessionsRef.current) { sendMetricsHeartbeat(sessionId).catch(() => {}); } }, 30000); return () => clearInterval(heartbeat); - }, [hostMetrics]); + }, [hostMetrics, isVisible]); const handleClearActivity = async () => { try { @@ -1587,6 +1613,7 @@ export function DashboardTab({ onAddServiceLink: handleAddServiceLink, onDeleteServiceLink: handleDeleteServiceLink, statusLoading, + isVisible, }; const isMobile = useIsMobile(); @@ -1707,7 +1734,7 @@ export function DashboardTab({ )} {slot.id === "host_status" && ( onOpenSingletonTab("network_graph")} /> )} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index 74fb2ce0..5495ea13 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -92,6 +92,8 @@ interface NetworkGraphCardProps { rightSidebarWidth?: number; embedded?: boolean; onOpenInNewTab?: () => void; + /** When false, pause status refresh while the surface stays mounted. */ + isVisible?: boolean; } type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; @@ -195,6 +197,7 @@ function buildNodeSvg( export function NetworkGraphCard({ embedded = true, onOpenInNewTab, + isVisible = true, }: NetworkGraphCardProps): React.ReactElement { const { t } = useTranslation(); const { addTab } = useTabsSafe(); @@ -269,8 +272,19 @@ export function NetworkGraphCard({ }, [hostMap]); useEffect(() => { + if (!isVisible) { + if (statusIntervalRef.current) { + clearInterval(statusIntervalRef.current); + statusIntervalRef.current = null; + } + return; + } + loadData(); - statusIntervalRef.current = setInterval(updateHostStatuses, 30000); + statusIntervalRef.current = setInterval(() => { + if (document.visibilityState === "hidden") return; + updateHostStatuses(); + }, 30000); const onClickOutside = (e: MouseEvent) => { if ( contextMenuRef.current && @@ -293,7 +307,7 @@ export function NetworkGraphCard({ document.removeEventListener("mousedown", onClickOutside, true); themeObserver.disconnect(); }; - }, []); + }, [isVisible]); const loadData = async () => { setLoading(true); diff --git a/src/ui/features/docker/DockerManager.tsx b/src/ui/features/docker/DockerManager.tsx index ebac94b9..70ee1b2f 100644 --- a/src/ui/features/docker/DockerManager.tsx +++ b/src/ui/features/docker/DockerManager.tsx @@ -335,7 +335,10 @@ function DockerManagerInner({ }; pollContainers(); - const interval = setInterval(pollContainers, 5000); + const interval = setInterval(() => { + if (document.visibilityState === "hidden") return; + void pollContainers(); + }, 5000); return () => { cancelled = true; diff --git a/src/ui/features/docker/components/ContainerStats.tsx b/src/ui/features/docker/components/ContainerStats.tsx index 2749dd31..c4dba44a 100644 --- a/src/ui/features/docker/components/ContainerStats.tsx +++ b/src/ui/features/docker/components/ContainerStats.tsx @@ -50,7 +50,10 @@ export function ContainerStats({ React.useEffect(() => { fetchStats(); - const interval = setInterval(fetchStats, 2000); + const interval = setInterval(() => { + if (document.visibilityState === "hidden") return; + void fetchStats(); + }, 2000); return () => clearInterval(interval); }, [fetchStats]); diff --git a/src/ui/features/docker/components/LogViewer.tsx b/src/ui/features/docker/components/LogViewer.tsx index bca7b51e..95e35eaf 100644 --- a/src/ui/features/docker/components/LogViewer.tsx +++ b/src/ui/features/docker/components/LogViewer.tsx @@ -90,7 +90,10 @@ export function LogViewer({ React.useEffect(() => { if (!autoRefresh) return; - const interval = setInterval(fetchLogs, 3000); + const interval = setInterval(() => { + if (document.visibilityState === "hidden") return; + void fetchLogs(); + }, 3000); return () => clearInterval(interval); }, [autoRefresh, fetchLogs]); diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 70ff98e3..a8bced7d 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -87,6 +87,7 @@ function FileManagerContent({ initialPath, onClose, onOpenTerminalTab, + isVisible = true, }: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); @@ -268,7 +269,7 @@ function FileManagerContent({ }, [currentHost]); useEffect(() => { - if (sshSessionId) { + if (sshSessionId && isVisible) { startKeepalive(); } else { stopKeepalive(); @@ -277,7 +278,7 @@ function FileManagerContent({ return () => { stopKeepalive(); }; - }, [sshSessionId, startKeepalive, stopKeepalive]); + }, [sshSessionId, isVisible, startKeepalive, stopKeepalive]); const initialFileOpenedRef = useRef(false); useEffect(() => { @@ -3105,6 +3106,7 @@ function FileManagerInner({ initialPath, onClose, onOpenTerminalTab, + isVisible = true, }: FileManagerProps) { return ( @@ -3114,6 +3116,7 @@ function FileManagerInner({ initialPath={initialPath} onClose={onClose} onOpenTerminalTab={onOpenTerminalTab} + isVisible={isVisible} /> ); diff --git a/src/ui/features/file-manager/FileManagerGrid.tsx b/src/ui/features/file-manager/FileManagerGrid.tsx index 6656220b..c2ecd3ec 100644 --- a/src/ui/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/features/file-manager/FileManagerGrid.tsx @@ -1,6 +1,14 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React, { + useState, + useRef, + useCallback, + useEffect, + useMemo, + useLayoutEffect, +} from "react"; import { createPortal } from "react-dom"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { cn } from "@/lib/utils.ts"; import { Folder, @@ -185,6 +193,12 @@ export function FileManagerGrid({ const { t } = useTranslation(); const gridRef = useRef(null); const [editingName, setEditingName] = useState(""); + const [gridCols, setGridCols] = useState(4); + + const LIST_ROW_H = 41; + const GRID_ROW_H = 112; + const LIST_HEADER_H = 33; + const CONTENT_PAD = 16; const [dragState, setDragState] = useState({ type: "none", @@ -192,6 +206,52 @@ export function FileManagerGrid({ counter: 0, }); + // Responsive column count for grid virtualization (matches Tailwind breakpoints roughly). + useEffect(() => { + if (viewMode !== "grid") return; + const el = gridRef.current; + if (!el) return; + + const updateCols = () => { + const w = el.clientWidth - CONTENT_PAD * 2; + // gap-4 (16px) + ~min cell 96px + const n = Math.max(2, Math.min(8, Math.floor((w + 16) / 112))); + setGridCols(n); + }; + updateCols(); + const ro = new ResizeObserver(updateCols); + ro.observe(el); + return () => ro.disconnect(); + }, [viewMode]); + + const gridRowCount = useMemo( + () => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0), + [viewMode, files.length, gridCols], + ); + + const listVirtualizer = useVirtualizer({ + count: viewMode === "list" ? files.length : 0, + getScrollElement: () => gridRef.current, + estimateSize: () => LIST_ROW_H, + overscan: 16, + getItemKey: (index) => files[index]?.path ?? index, + enabled: viewMode === "list" && files.length > 0, + }); + + const gridVirtualizer = useVirtualizer({ + count: gridRowCount, + getScrollElement: () => gridRef.current, + estimateSize: () => GRID_ROW_H, + overscan: 6, + getItemKey: (index) => `row-${index}`, + enabled: viewMode === "grid" && files.length > 0, + }); + + useLayoutEffect(() => { + if (viewMode === "list") listVirtualizer.measure(); + else gridVirtualizer.measure(); + }, [viewMode, files.length, editingFile?.path, createIntent, gridCols]); + useEffect(() => { const handleGlobalMouseMove = (e: MouseEvent) => { if (dragState.type === "internal" && dragState.files.length > 0) { @@ -442,6 +502,77 @@ export function FileManagerGrid({ setSelectionRect({ x, y, width, height }); if (gridRef.current) { + const selectionBox = { + left: x, + top: y, + right: x + width, + bottom: y + height, + }; + + // Virtual list: only visible DOM nodes exist. For list mode compute + // selection from scroll position + fixed row height so drag-select + // still covers off-screen rows. + if (viewMode === "list" && files.length > 0) { + const scrollTop = gridRef.current.scrollTop; + const createExtra = createIntent ? LIST_ROW_H : 0; + const contentTop = + selectionBox.top + + scrollTop - + CONTENT_PAD - + LIST_HEADER_H - + createExtra; + const contentBottom = + selectionBox.bottom + + scrollTop - + CONTENT_PAD - + LIST_HEADER_H - + createExtra; + const startIdx = Math.max(0, Math.floor(contentTop / LIST_ROW_H)); + const endIdx = Math.min( + files.length - 1, + Math.ceil(contentBottom / LIST_ROW_H), + ); + if (endIdx >= startIdx) { + onSelectionChange(files.slice(startIdx, endIdx + 1)); + } else { + onSelectionChange([]); + } + return; + } + + if (viewMode === "grid" && files.length > 0) { + const scrollTop = gridRef.current.scrollTop; + const createExtra = createIntent ? GRID_ROW_H : 0; + const contentTop = + selectionBox.top + scrollTop - CONTENT_PAD - createExtra; + const contentBottom = + selectionBox.bottom + scrollTop - CONTENT_PAD - createExtra; + const startRow = Math.max(0, Math.floor(contentTop / GRID_ROW_H)); + const endRow = Math.min( + gridRowCount - 1, + Math.ceil(contentBottom / GRID_ROW_H), + ); + // Column range from X within padded content. + const contentLeft = selectionBox.left - CONTENT_PAD; + const contentRight = selectionBox.right - CONTENT_PAD; + const cellW = + (gridRef.current.clientWidth - CONTENT_PAD * 2 + 16) / gridCols; + const startCol = Math.max(0, Math.floor(contentLeft / cellW)); + const endCol = Math.min( + gridCols - 1, + Math.ceil(contentRight / cellW), + ); + const selected: FileItem[] = []; + for (let r = startRow; r <= endRow; r++) { + for (let c = startCol; c <= endCol; c++) { + const idx = r * gridCols + c; + if (idx >= 0 && idx < files.length) selected.push(files[idx]); + } + } + onSelectionChange(selected); + return; + } + const fileElements = gridRef.current.querySelectorAll("[data-file-path]"); const selectedPaths: string[] = []; @@ -457,13 +588,6 @@ export function FileManagerGrid({ bottom: elementRect.bottom - containerRect.top, }; - const selectionBox = { - left: x, - top: y, - right: x + width, - bottom: y + height, - }; - const intersects = !( relativeElementRect.right < selectionBox.left || relativeElementRect.left > selectionBox.right || @@ -486,7 +610,16 @@ export function FileManagerGrid({ } } }, - [isSelecting, selectionStart, files, onSelectionChange], + [ + isSelecting, + selectionStart, + files, + onSelectionChange, + viewMode, + createIntent, + gridCols, + gridRowCount, + ], ); const handleMouseUp = useCallback( @@ -819,89 +952,126 @@ export function FileManagerGrid({ ) : viewMode === "grid" ? ( -
+
{createIntent && ( - +
+ +
)} - {files.map((file) => { - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); - - return ( -
f.path === file.path) && - "opacity-50", - )} - title={file.name} - onClick={(e) => handleFileClick(file, e)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e, file); - }} - onDragStart={(e) => handleFileDragStart(e, file)} - onDragOver={(e) => handleFileDragOver(e, file)} - onDragLeave={(e) => handleFileDragLeave(e, file)} - onDrop={(e) => handleFileDrop(e, file)} - onDragEnd={handleFileDragEnd} - > -
- {getFileIcon(file, viewMode)} +
+ {gridVirtualizer.getVirtualItems().map((vRow) => { + const start = vRow.index * gridCols; + const rowFiles = files.slice(start, start + gridCols); + return ( +
+
+ {rowFiles.map((file) => { + const isSelected = selectedFiles.some( + (f) => f.path === file.path, + ); + return ( +
f.path === file.path, + ) && "opacity-50", + )} + title={file.name} + onClick={(e) => handleFileClick(file, e)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e, file); + }} + onDragStart={(e) => handleFileDragStart(e, file)} + onDragOver={(e) => handleFileDragOver(e, file)} + onDragLeave={(e) => handleFileDragLeave(e, file)} + onDrop={(e) => handleFileDrop(e, file)} + onDragEnd={handleFileDragEnd} + > +
+ {getFileIcon(file, viewMode)} +
+
+ {editingFile?.path === file.path ? ( + + setEditingName(e.target.value) + } + onKeyDown={handleEditKeyDown} + onBlur={handleEditConfirm} + className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto" + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + /> + ) : ( +

+ {file.name} +

+ )} + {file.type === "file" && + file.size !== undefined && + file.size !== null && ( +

+ {formatFileSize(file.size)} +

+ )} + {file.type === "link" && file.linkTarget && ( +

+ → {file.linkTarget} +

+ )} +
+
+ ); + })} +
- -
- {editingFile?.path === file.path ? ( - setEditingName(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={handleEditConfirm} - className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto" - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - /> - ) : ( -

- {file.name} -

- )} - {file.type === "file" && - file.size !== undefined && - file.size !== null && ( -

- {formatFileSize(file.size)} -

- )} - {file.type === "link" && file.linkTarget && ( -

- → {file.linkTarget} -

- )} -
-
- ); - })} + ); + })} +
) : (
@@ -952,91 +1122,106 @@ export function FileManagerGrid({ onCancel={onCancelCreate} /> )} - {files.map((file) => { - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); - - return ( -
f.path === file.path) && - "opacity-50", - )} - onClick={(e) => handleFileClick(file, e)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e, file); - }} - onDragStart={(e) => handleFileDragStart(e, file)} - onDragOver={(e) => handleFileDragOver(e, file)} - onDragLeave={(e) => handleFileDragLeave(e, file)} - onDrop={(e) => handleFileDrop(e, file)} - onDragEnd={handleFileDragEnd} - > -
-
- {getFileIcon(file, viewMode)} -
- {editingFile?.path === file.path ? ( - setEditingName(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={handleEditConfirm} - className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto" - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - /> - ) : ( - - {file.name} - {file.type === "link" && file.linkTarget && ( - - → {file.linkTarget} +
+ {listVirtualizer.getVirtualItems().map((vItem) => { + const file = files[vItem.index]; + if (!file) return null; + const isSelected = selectedFiles.some( + (f) => f.path === file.path, + ); + return ( +
+
f.path === file.path) && + "opacity-50", + )} + onClick={(e) => handleFileClick(file, e)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e, file); + }} + onDragStart={(e) => handleFileDragStart(e, file)} + onDragOver={(e) => handleFileDragOver(e, file)} + onDragLeave={(e) => handleFileDragLeave(e, file)} + onDrop={(e) => handleFileDrop(e, file)} + onDragEnd={handleFileDragEnd} + > +
+
+ {getFileIcon(file, viewMode)} +
+ {editingFile?.path === file.path ? ( + setEditingName(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={handleEditConfirm} + className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto" + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + /> + ) : ( + + {file.name} + {file.type === "link" && file.linkTarget && ( + + → {file.linkTarget} + + )} )} +
+ + + {file.modified || "—"} - )} + + + {file.owner + ? `${file.owner}${file.group ? `:${file.group}` : ""}` + : "—"} + + + + {file.type === "file" && + file.size !== undefined && + file.size !== null + ? formatFileSize(file.size) + : "—"} + + + + {file.permissions || "—"} + +
- - - {file.modified || "—"} - - - - {file.owner - ? `${file.owner}${file.group ? `:${file.group}` : ""}` - : "—"} - - - - {file.type === "file" && - file.size !== undefined && - file.size !== null - ? formatFileSize(file.size) - : "—"} - - - - {file.permissions || "—"} - -
- ); - })} + ); + })} +
)} diff --git a/src/ui/features/file-manager/TransferMonitor.tsx b/src/ui/features/file-manager/TransferMonitor.tsx index fade734b..b85a84e0 100644 --- a/src/ui/features/file-manager/TransferMonitor.tsx +++ b/src/ui/features/file-manager/TransferMonitor.tsx @@ -71,9 +71,34 @@ export function TransferMonitor() { } }; + let intervalId: ReturnType | null = null; + + const start = () => { + if (intervalId !== null) return; + intervalId = setInterval(reconcileTransfers, POLL_INTERVAL_MS); + }; + const stop = () => { + if (intervalId === null) return; + clearInterval(intervalId); + intervalId = null; + }; + const onVisibility = () => { + if (document.visibilityState === "hidden") { + stop(); + return; + } + void reconcileTransfers(); + start(); + }; + void reconcileTransfers(); - const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS); - return () => clearInterval(interval); + if (document.visibilityState !== "hidden") start(); + document.addEventListener("visibilitychange", onVisibility); + + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibility); + }; }, [t, formatTransferMetrics]); return null; diff --git a/src/ui/features/file-manager/components/FileViewer.tsx b/src/ui/features/file-manager/components/FileViewer.tsx index e4cc6287..fa581650 100644 --- a/src/ui/features/file-manager/components/FileViewer.tsx +++ b/src/ui/features/file-manager/components/FileViewer.tsx @@ -88,6 +88,7 @@ interface FileViewerProps { content?: string; savedContent?: string; isLoading?: boolean; + isSaving?: boolean; isEditable?: boolean; resetKey?: number; onContentChange?: (content: string) => void; @@ -271,6 +272,7 @@ export function FileViewer({ content = "", savedContent = "", isLoading = false, + isSaving = false, isEditable = false, resetKey, onContentChange, @@ -353,6 +355,7 @@ export function FileViewer({ }; const handleSave = () => { + if (isSaving) return; onSave?.(editedContent); }; @@ -407,7 +410,7 @@ export function FileViewer({ return (
-
+
{fileTypeInfo.icon} @@ -434,7 +437,7 @@ export function FileViewer({
-
+
{isEditable && ( + + {formatPosition(position)} + + onSeek(Number(event.target.value))} + className="min-w-0 flex-1 accent-primary" + aria-label="Recording timeline" + /> + + {formatPosition(duration)} + + +
+ ); +} + +function AsciicastPlayer({ blob }: { blob: Blob }) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const recordingRef = useRef(null); + const positionRef = useRef(0); + const eventIndexRef = useRef(0); + const [position, setPosition] = useState(0); + const [duration, setDuration] = useState(0); + const [speed, setSpeed] = useState(1); + const [playing, setPlaying] = useState(false); + const [error, setError] = useState(""); + + const renderAt = useCallback((nextPosition: number) => { + const terminal = terminalRef.current; + const recording = recordingRef.current; + if (!terminal || !recording) return; + if (nextPosition < positionRef.current) { + terminal.reset(); + terminal.resize(recording.width, recording.height); + eventIndexRef.current = 0; + } + while (eventIndexRef.current < recording.events.length) { + const [time, type, data] = recording.events[eventIndexRef.current]; + if (time > nextPosition) break; + if (type === "o") terminal.write(data); + if (type === "r") { + const [cols, rows] = data.split("x").map(Number); + if (cols > 0 && rows > 0) terminal.resize(cols, rows); + } + eventIndexRef.current++; + } + positionRef.current = nextPosition; + setPosition(nextPosition); + }, []); + + useEffect(() => { + if (!containerRef.current) return; + const terminal = new Terminal({ + cursorBlink: false, + disableStdin: true, + convertEol: false, + fontSize: 12, + theme: { background: "#09090b" }, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(containerRef.current); + terminalRef.current = terminal; + const observer = new ResizeObserver(() => fitAddon.fit()); + observer.observe(containerRef.current); + fitAddon.fit(); + + blob + .text() + .then((source) => { + const recording = parseAsciicast(source); + recordingRef.current = recording; + setDuration(recording.duration); + terminal.resize(recording.width, recording.height); + renderAt(0); + }) + .catch((reason) => + setError(reason instanceof Error ? reason.message : String(reason)), + ); + + return () => { + observer.disconnect(); + terminal.dispose(); + terminalRef.current = null; + }; + }, [blob, renderAt]); + + useEffect(() => { + if (!playing || !recordingRef.current) return; + let frame = 0; + let previous = performance.now(); + const tick = (now: number) => { + const next = Math.min( + duration, + positionRef.current + ((now - previous) / 1000) * speed, + ); + previous = now; + renderAt(next); + if (next >= duration) setPlaying(false); + else frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [duration, playing, renderAt, speed]); + + if (error) return
{error}
; + return ( +
+
+ { + if (!playing && position >= duration) renderAt(0); + setPlaying((value) => !value); + }} + onSeek={renderAt} + onSpeed={setSpeed} + /> +
+ ); +} + +function GuacamolePlayer({ blob }: { blob: Blob }) { + const containerRef = useRef(null); + const recordingRef = useRef(null); + const positionRef = useRef(0); + const [position, setPosition] = useState(0); + const [duration, setDuration] = useState(0); + const [speed, setSpeed] = useState(1); + const [playing, setPlaying] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!containerRef.current) return; + const recording = new Guacamole.SessionRecording(blob, 100); + recordingRef.current = recording; + const display = recording.getDisplay(); + const element = display.getElement(); + containerRef.current.replaceChildren(element); + const fit = () => { + const width = display.getWidth(); + if (width && containerRef.current) { + display.scale(containerRef.current.clientWidth / width); + } + }; + const observer = new ResizeObserver(fit); + observer.observe(containerRef.current); + recording.onload = () => { + setDuration(recording.getDuration() / 1000); + fit(); + }; + recording.onprogress = (nextDuration) => setDuration(nextDuration / 1000); + recording.onseek = (nextPosition) => { + positionRef.current = nextPosition / 1000; + setPosition(positionRef.current); + }; + recording.onerror = setError; + + return () => { + observer.disconnect(); + recording.abort(); + recordingRef.current = null; + }; + }, [blob]); + + useEffect(() => { + const recording = recordingRef.current; + if (!recording || !playing) return; + recording.pause(); + let previous = performance.now(); + let nextPosition = positionRef.current; + const interval = window.setInterval(() => { + const now = performance.now(); + nextPosition = Math.min( + duration, + nextPosition + ((now - previous) / 1000) * speed, + ); + previous = now; + recording.seek(nextPosition * 1000); + if (nextPosition >= duration) setPlaying(false); + }, 100); + return () => clearInterval(interval); + }, [duration, playing, speed]); + + if (error) return
{error}
; + return ( +
+
+ { + const recording = recordingRef.current; + if (!recording) return; + if (!playing && position >= duration) recording.seek(0); + setPlaying((value) => !value); + }} + onSeek={(nextPosition) => { + recordingRef.current?.seek(nextPosition * 1000); + positionRef.current = nextPosition; + setPosition(nextPosition); + }} + onSpeed={setSpeed} + /> +
+ ); +} + +export function SessionRecordingPlayer({ + log, + blob, +}: { + log: SessionLogRecord; + blob: Blob; +}) { + return log.format === "guacamole" ? ( + + ) : ( + + ); +} diff --git a/src/ui/features/session-recording/asciicast.ts b/src/ui/features/session-recording/asciicast.ts new file mode 100644 index 00000000..bf13ac30 --- /dev/null +++ b/src/ui/features/session-recording/asciicast.ts @@ -0,0 +1,39 @@ +export type AsciicastEvent = [ + time: number, + type: "i" | "o" | "r", + data: string, +]; + +export type Asciicast = { + width: number; + height: number; + duration: number; + events: AsciicastEvent[]; +}; + +export function parseAsciicast(source: string): Asciicast { + const lines = source.trim().split("\n"); + const header = JSON.parse(lines.shift() || "{}") as { + version?: number; + width?: number; + height?: number; + }; + if (header.version !== 2) throw new Error("Unsupported asciicast version"); + + const events = lines + .filter(Boolean) + .map((line) => JSON.parse(line) as AsciicastEvent) + .filter( + ([time, type, data]) => + Number.isFinite(time) && + (type === "i" || type === "o" || type === "r") && + typeof data === "string", + ); + + return { + width: header.width || 80, + height: header.height || 24, + duration: events.at(-1)?.[0] || 0, + events, + }; +} diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 836eb1b5..7eda9981 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -55,8 +55,13 @@ import { import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx"; import { toast } from "sonner"; import { Button } from "@/components/button"; +import { Save } from "lucide-react"; import { resolveTermixThemeColors } from "./terminal-theme.ts"; import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; +import { + getNextTerminalFontSize, + getTerminalFontZoomDirection, +} from "./terminal-font-zoom.ts"; export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; type HostKeyVerificationData = Omit< @@ -81,10 +86,10 @@ interface SSHTerminalProps { previewTheme?: string | null; /** When true, suppress automatic focus on connect/visibility change. */ disableAutoFocus?: boolean; + isQuickConnect?: boolean; + onSaveQuickConnect?: () => Promise; } -const TERMINAL_FONT_ZOOM_MIN = 8; -const TERMINAL_FONT_ZOOM_MAX = 36; const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g; function updateAlternateScreenMode(output: string, currentMode: boolean) { @@ -116,6 +121,8 @@ const TerminalInner = forwardRef( onOpenFileInEditor, previewTheme, disableAutoFocus = false, + isQuickConnect = false, + onSaveQuickConnect, }, ref, ) { @@ -157,6 +164,8 @@ const TerminalInner = forwardRef( const pongReceivedRef = useRef(true); const pongTimeoutRef = useRef(null); const [isConnected, setIsConnected] = useState(false); + const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false); + const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isFitted, setIsFitted] = useState(false); const [connectionError, setConnectionError] = useState(null); @@ -223,6 +232,20 @@ const TerminalInner = forwardRef( url: string; } | null>(null); + useEffect(() => { + if (!linkClickDialog) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + setLinkClickDialog(null); + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [linkClickDialog]); + const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{ sessions: Array<{ name: string; @@ -416,20 +439,8 @@ const TerminalInner = forwardRef( }, [isVisible]); useEffect(() => { - const checkAuth = () => { - setIsAuthenticated((prev) => { - if (!prev) { - return true; - } - return prev; - }); - }; - - checkAuth(); - - const authCheckInterval = setInterval(checkAuth, 5000); - - return () => clearInterval(authCheckInterval); + // One-shot: historical code polled every 5s but only ever flipped false→true. + setIsAuthenticated(true); }, []); function hardRefresh() { @@ -483,16 +494,12 @@ const TerminalInner = forwardRef( } } - function zoomTerminalFont(deltaY: number) { - const direction = deltaY < 0 ? 1 : -1; + function changeTerminalFontSize(direction: -1 | 1) { const currentFontSize = terminal.options.fontSize ?? terminalFontSizeRef.current ?? DEFAULT_TERMINAL_CONFIG.fontSize; - const nextFontSize = Math.min( - TERMINAL_FONT_ZOOM_MAX, - Math.max(TERMINAL_FONT_ZOOM_MIN, currentFontSize + direction), - ); + const nextFontSize = getNextTerminalFontSize(currentFontSize, direction); if (nextFontSize === currentFontSize) { return; @@ -2153,7 +2160,7 @@ const TerminalInner = forwardRef( terminal.attachCustomWheelEventHandler((ev) => { if (ev.ctrlKey || ev.metaKey) { - zoomTerminalFont(ev.deltaY); + changeTerminalFontSize(ev.deltaY < 0 ? 1 : -1); return false; } @@ -2276,9 +2283,11 @@ const TerminalInner = forwardRef( element?.addEventListener("keydown", handleTabCapture, true); const resizeObserver = new ResizeObserver(() => { + // Background keep-alive tabs still observe layout; skip fit work. + if (!isVisibleRef.current) return; if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { - if (isVisible) { + if (isVisibleRef.current) { performFit(); } }, 50); @@ -2384,6 +2393,14 @@ const TerminalInner = forwardRef( } } + const fontZoomDirection = getTerminalFontZoomDirection(e); + if (fontZoomDirection !== 0) { + e.preventDefault(); + e.stopPropagation(); + changeTerminalFontSize(fontZoomDirection); + return false; + } + if ( e.ctrlKey && !e.shiftKey && @@ -2800,6 +2817,32 @@ const TerminalInner = forwardRef( )} + {isQuickConnect && + isConnected && + !isQuickConnectSaved && + onSaveQuickConnect && ( + + )} + (
setLinkClickDialog(null)} >
event.stopPropagation()} >

{t("terminal.linkDialogTitle")} diff --git a/src/ui/features/terminal/TerminalPreview.tsx b/src/ui/features/terminal/TerminalPreview.tsx index 4b8e7cd3..718fe491 100644 --- a/src/ui/features/terminal/TerminalPreview.tsx +++ b/src/ui/features/terminal/TerminalPreview.tsx @@ -45,7 +45,7 @@ export function TerminalPreview({ return (

onSelect(suggestion)} onMouseEnter={() => {}} diff --git a/src/ui/features/terminal/terminal-font-zoom.ts b/src/ui/features/terminal/terminal-font-zoom.ts new file mode 100644 index 00000000..72dd6676 --- /dev/null +++ b/src/ui/features/terminal/terminal-font-zoom.ts @@ -0,0 +1,37 @@ +export const TERMINAL_FONT_ZOOM_MIN = 8; +export const TERMINAL_FONT_ZOOM_MAX = 36; + +type ZoomKeyEvent = Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "key" | "metaKey" +>; + +export function getTerminalFontZoomDirection(event: ZoomKeyEvent): -1 | 0 | 1 { + if ((!event.ctrlKey && !event.metaKey) || event.altKey) return 0; + + if ( + event.key === "+" || + event.code === "Equal" || + event.code === "NumpadAdd" + ) { + return 1; + } + if ( + event.key === "-" || + event.code === "Minus" || + event.code === "NumpadSubtract" + ) { + return -1; + } + return 0; +} + +export function getNextTerminalFontSize( + currentFontSize: number, + direction: -1 | 1, +): number { + return Math.min( + TERMINAL_FONT_ZOOM_MAX, + Math.max(TERMINAL_FONT_ZOOM_MIN, currentFontSize + direction), + ); +} diff --git a/src/ui/features/terminal/terminal-global-styles.ts b/src/ui/features/terminal/terminal-global-styles.ts index 15913c39..a5a1b60f 100644 --- a/src/ui/features/terminal/terminal-global-styles.ts +++ b/src/ui/features/terminal/terminal-global-styles.ts @@ -66,7 +66,6 @@ style.innerHTML = ` } .xterm .xterm-screen { - font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important; font-variant-ligatures: none; } diff --git a/src/ui/features/tmux-monitor/TmuxMonitor.tsx b/src/ui/features/tmux-monitor/TmuxMonitor.tsx index ea0124ef..aef211bb 100644 --- a/src/ui/features/tmux-monitor/TmuxMonitor.tsx +++ b/src/ui/features/tmux-monitor/TmuxMonitor.tsx @@ -241,9 +241,10 @@ export function TmuxMonitor({ // -- relative time refresh -------------------------------------------------- useEffect(() => { + if (!isVisible) return; const interval = setInterval(() => setNow(Date.now()), TIME_TICK_MS); return () => clearInterval(interval); - }, []); + }, [isVisible]); // -- overview polling ----------------------------------------------------- const loadOverview = useCallback( diff --git a/src/ui/features/tunnel/TunnelTab.tsx b/src/ui/features/tunnel/TunnelTab.tsx index 7f155d46..0d21efbc 100644 --- a/src/ui/features/tunnel/TunnelTab.tsx +++ b/src/ui/features/tunnel/TunnelTab.tsx @@ -239,7 +239,14 @@ function TunnelCard({ ); } -export function TunnelTab({ host }: { label: string; host?: DemoHost }) { +export function TunnelTab({ + host, + isVisible = true, +}: { + label: string; + host?: DemoHost; + isVisible?: boolean; +}) { const { t } = useTranslation(); const [sshHost, setSshHost] = useState(null); const [tunnelStatuses, setTunnelStatuses] = useState< @@ -264,14 +271,40 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) { }, [host]); useEffect(() => { + if (!isVisible) return; + fetchHost(); - const interval = setInterval(fetchHost, 5000); window.addEventListener("ssh-hosts:changed", fetchHost); + + let intervalId: ReturnType | null = null; + const start = () => { + if (intervalId !== null) return; + // Host config rarely changes; tunnel runtime status uses subscribeTunnelStatuses. + intervalId = setInterval(fetchHost, 30_000); + }; + const stop = () => { + if (intervalId === null) return; + clearInterval(intervalId); + intervalId = null; + }; + const onVisibility = () => { + if (document.visibilityState === "hidden") { + stop(); + return; + } + void fetchHost(); + start(); + }; + + if (document.visibilityState !== "hidden") start(); + document.addEventListener("visibilitychange", onVisibility); + return () => { - clearInterval(interval); + stop(); + document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("ssh-hosts:changed", fetchHost); }; - }, [fetchHost]); + }, [fetchHost, isVisible]); useEffect(() => { return subscribeTunnelStatuses(setTunnelStatuses, () => {}); diff --git a/src/ui/hooks/use-page-visible-interval.ts b/src/ui/hooks/use-page-visible-interval.ts new file mode 100644 index 00000000..c01a6465 --- /dev/null +++ b/src/ui/hooks/use-page-visible-interval.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from "react"; + +function isDocumentVisible(): boolean { + if (typeof document === "undefined") return true; + return document.visibilityState !== "hidden"; +} + +type Options = { + /** Run callback once when the effect mounts (if visible). Default true. */ + runOnMount?: boolean; +}; + +/** + * Runs `callback` on `intervalMs` while the page is visible. + * Pauses when the tab is backgrounded; fires once on resume. + */ +export function usePageVisibleInterval( + callback: () => void, + intervalMs: number, + enabled = true, + options: Options = {}, +): void { + const { runOnMount = true } = options; + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!enabled || intervalMs <= 0) return; + + let intervalId: ReturnType | null = null; + + const tick = () => { + callbackRef.current(); + }; + + const start = () => { + if (intervalId !== null) return; + intervalId = setInterval(tick, intervalMs); + }; + + const stop = () => { + if (intervalId === null) return; + clearInterval(intervalId); + intervalId = null; + }; + + const onVisibility = () => { + if (isDocumentVisible()) { + tick(); + start(); + } else { + stop(); + } + }; + + if (isDocumentVisible()) { + if (runOnMount) tick(); + start(); + } + + document.addEventListener("visibilitychange", onVisibility); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [intervalMs, enabled, runOnMount]); +} diff --git a/src/ui/i18n/i18n.ts b/src/ui/i18n/i18n.ts index 392d1fe0..22686032 100644 --- a/src/ui/i18n/i18n.ts +++ b/src/ui/i18n/i18n.ts @@ -43,31 +43,43 @@ const localeLoaders = { "zh-TW": () => import("../locales/translated/zh_TW.json"), } satisfies Record Promise>; -const supportedLngs = ["en", ...Object.keys(localeLoaders)]; +export const supportedLngs = ["en", ...Object.keys(localeLoaders)]; + +export function normalizeLanguageCode(language?: string | null): string { + if (!language) return "en"; + + const normalized = language.replaceAll("_", "-"); + if (supportedLngs.includes(normalized)) return normalized; + + const exactMatch = supportedLngs.find( + (supported) => supported.toLowerCase() === normalized.toLowerCase(), + ); + if (exactMatch) return exactMatch; + + const baseLanguage = normalized.split("-")[0]; + return supportedLngs.includes(baseLanguage) ? baseLanguage : "en"; +} const localeBackend: BackendModule = { type: "backend", init: () => {}, read: (language, _namespace, callback) => { - if (language === "en") { + const normalizedLanguage = normalizeLanguageCode(language); + + if (normalizedLanguage === "en") { callback(null, enTranslation); return; } - const loadLocale = localeLoaders[language]; + const loadLocale = localeLoaders[normalizedLanguage]; if (!loadLocale) { - callback(new Error(`Unsupported language: ${language}`), false); + callback(null, enTranslation); return; } loadLocale() .then((module) => callback(null, module.default)) - .catch((error: unknown) => { - callback( - error instanceof Error ? error : new Error(String(error)), - false, - ); - }); + .catch(() => callback(null, enTranslation)); }, }; @@ -104,4 +116,11 @@ i18n }, }); +export async function changeAppLanguage(language: string): Promise { + const normalizedLanguage = normalizeLanguageCode(language); + await i18n.changeLanguage(normalizedLanguage); + localStorage.setItem("i18nextLng", normalizedLanguage); + return normalizedLanguage; +} + export default i18n; diff --git a/src/ui/index.css b/src/ui/index.css index 29047c70..042c8af6 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -176,24 +176,24 @@ .dracula { --background: #282a36; --foreground: #f8f8f2; - --card: #21222c; + --card: #2d303d; --card-foreground: #f8f8f2; - --popover: #21222c; + --popover: #2d303d; --popover-foreground: #f8f8f2; - --primary: #f8f8f2; + --primary: #8be9fd; --primary-foreground: #282a36; - --secondary: #44475a; + --secondary: #50536b; --secondary-foreground: #f8f8f2; - --surface: #21222c; - --surface-dim: #191a21; - --muted: #44475a; - --muted-foreground: #6272a4; - --accent: #44475a; + --surface: #2d303d; + --surface-dim: #21222c; + --muted: #50536b; + --muted-foreground: #a8b3d9; + --accent: #5a5e7a; --accent-foreground: #f8f8f2; --destructive: #ff5555; - --border: rgba(98, 114, 164, 0.25); - --input: rgba(98, 114, 164, 0.2); - --ring: #6272a4; + --border: rgba(168, 179, 217, 0.18); + --input: rgba(168, 179, 217, 0.14); + --ring: #8be9fd; --chart-1: #ff79c6; --chart-2: #8be9fd; --chart-3: #50fa7b; @@ -202,36 +202,36 @@ --radius: 0.625rem; --sidebar: #21222c; --sidebar-foreground: #f8f8f2; - --sidebar-primary: #bd93f9; + --sidebar-primary: #8be9fd; --sidebar-primary-foreground: #282a36; - --sidebar-accent: #44475a; + --sidebar-accent: #5a5e7a; --sidebar-accent-foreground: #f8f8f2; - --sidebar-border: rgba(98, 114, 164, 0.25); - --sidebar-ring: #6272a4; + --sidebar-border: rgba(168, 179, 217, 0.18); + --sidebar-ring: #8be9fd; } /* ── Catppuccin Mocha ───────────────────────────────────────── */ .catppuccin { --background: #1e1e2e; --foreground: #cdd6f4; - --card: #181825; + --card: #202235; --card-foreground: #cdd6f4; - --popover: #181825; + --popover: #202235; --popover-foreground: #cdd6f4; - --primary: #cdd6f4; + --primary: #89b4fa; --primary-foreground: #1e1e2e; - --secondary: #313244; + --secondary: #3b3d52; --secondary-foreground: #cdd6f4; - --surface: #181825; - --surface-dim: #11111b; - --muted: #313244; - --muted-foreground: #6c7086; - --accent: #313244; + --surface: #202235; + --surface-dim: #181825; + --muted: #3b3d52; + --muted-foreground: #b8c0e0; + --accent: #45475a; --accent-foreground: #cdd6f4; --destructive: #f38ba8; - --border: rgba(108, 112, 134, 0.25); - --input: rgba(108, 112, 134, 0.2); - --ring: #6c7086; + --border: rgba(186, 194, 222, 0.18); + --input: rgba(186, 194, 222, 0.14); + --ring: #89b4fa; --chart-1: #f38ba8; --chart-2: #89dceb; --chart-3: #a6e3a1; @@ -240,35 +240,35 @@ --radius: 0.625rem; --sidebar: #181825; --sidebar-foreground: #cdd6f4; - --sidebar-primary: #cba6f7; + --sidebar-primary: #89b4fa; --sidebar-primary-foreground: #1e1e2e; - --sidebar-accent: #313244; + --sidebar-accent: #45475a; --sidebar-accent-foreground: #cdd6f4; - --sidebar-border: rgba(108, 112, 134, 0.25); - --sidebar-ring: #6c7086; + --sidebar-border: rgba(186, 194, 222, 0.18); + --sidebar-ring: #89b4fa; } /* ── Nord ───────────────────────────────────────────────────── */ .nord { --background: #2e3440; --foreground: #eceff4; - --card: #303642; + --card: #353c4a; --card-foreground: #eceff4; - --popover: #303642; + --popover: #353c4a; --popover-foreground: #eceff4; - --primary: #eceff4; + --primary: #88c0d0; --primary-foreground: #2e3440; - --secondary: #3b4252; + --secondary: #434c5e; --secondary-foreground: #eceff4; - --surface: #303642; - --surface-dim: #262b35; - --muted: #3b4252; - --muted-foreground: #8899b0; - --accent: #3b4252; + --surface: #353c4a; + --surface-dim: #2a303b; + --muted: #434c5e; + --muted-foreground: #b4c0d4; + --accent: #4c566a; --accent-foreground: #eceff4; --destructive: #bf616a; - --border: rgba(136, 192, 208, 0.2); - --input: rgba(136, 192, 208, 0.15); + --border: rgba(180, 192, 212, 0.18); + --input: rgba(180, 192, 212, 0.14); --ring: #88c0d0; --chart-1: #bf616a; --chart-2: #88c0d0; @@ -276,38 +276,38 @@ --chart-4: #ebcb8b; --chart-5: #b48ead; --radius: 0.625rem; - --sidebar: #282d39; + --sidebar: #2a303b; --sidebar-foreground: #eceff4; --sidebar-primary: #88c0d0; --sidebar-primary-foreground: #2e3440; - --sidebar-accent: #3b4252; + --sidebar-accent: #4c566a; --sidebar-accent-foreground: #eceff4; - --sidebar-border: rgba(136, 192, 208, 0.2); + --sidebar-border: rgba(180, 192, 212, 0.18); --sidebar-ring: #88c0d0; } /* ── Solarized Dark ─────────────────────────────────────────── */ .solarized { --background: #002b36; - --foreground: #839496; + --foreground: #b7c4c4; --card: #073642; - --card-foreground: #93a1a1; + --card-foreground: #b7c4c4; --popover: #073642; - --popover-foreground: #93a1a1; - --primary: #93a1a1; + --popover-foreground: #b7c4c4; + --primary: #268bd2; --primary-foreground: #002b36; - --secondary: #073642; - --secondary-foreground: #839496; + --secondary: #0b4654; + --secondary-foreground: #b7c4c4; --surface: #073642; --surface-dim: #00212b; - --muted: #073642; - --muted-foreground: #586e75; - --accent: #073642; - --accent-foreground: #93a1a1; + --muted: #0b4654; + --muted-foreground: #9fb3b3; + --accent: #105667; + --accent-foreground: #d5dddd; --destructive: #dc322f; - --border: rgba(88, 110, 117, 0.3); - --input: rgba(88, 110, 117, 0.25); - --ring: #586e75; + --border: rgba(159, 179, 179, 0.2); + --input: rgba(159, 179, 179, 0.16); + --ring: #268bd2; --chart-1: #dc322f; --chart-2: #2aa198; --chart-3: #859900; @@ -315,37 +315,38 @@ --chart-5: #6c71c4; --radius: 0.625rem; --sidebar: #073642; - --sidebar-foreground: #93a1a1; + --sidebar-foreground: #b7c4c4; --sidebar-primary: #268bd2; --sidebar-primary-foreground: #002b36; - --sidebar-accent: #073642; - --sidebar-accent-foreground: #93a1a1; - --sidebar-border: rgba(88, 110, 117, 0.3); - --sidebar-ring: #586e75; + --sidebar-accent: #105667; + --sidebar-accent-foreground: #d5dddd; + --sidebar-border: rgba(159, 179, 179, 0.2); + --sidebar-ring: #268bd2; } /* ── Tokyo Night ────────────────────────────────────────────── */ + .tokyo-night { --background: #1a1b26; - --foreground: #a9b1d6; - --card: #16161e; - --card-foreground: #a9b1d6; - --popover: #16161e; - --popover-foreground: #a9b1d6; - --primary: #a9b1d6; + --foreground: #c0caf5; + --card: #1f2335; + --card-foreground: #c0caf5; + --popover: #1f2335; + --popover-foreground: #c0caf5; + --primary: #7aa2f7; --primary-foreground: #1a1b26; - --secondary: #24283b; - --secondary-foreground: #a9b1d6; - --surface: #16161e; - --surface-dim: #13131a; - --muted: #24283b; - --muted-foreground: #565f89; - --accent: #24283b; - --accent-foreground: #a9b1d6; + --secondary: #2a2f47; + --secondary-foreground: #c0caf5; + --surface: #1f2335; + --surface-dim: #16161e; + --muted: #2a2f47; + --muted-foreground: #8b93bd; + --accent: #2f3654; + --accent-foreground: #c0caf5; --destructive: #f7768e; - --border: rgba(86, 95, 137, 0.25); - --input: rgba(86, 95, 137, 0.2); - --ring: #565f89; + --border: rgba(139, 147, 189, 0.2); + --input: rgba(139, 147, 189, 0.16); + --ring: #7aa2f7; --chart-1: #f7768e; --chart-2: #7dcfff; --chart-3: #9ece6a; @@ -353,37 +354,37 @@ --chart-5: #bb9af7; --radius: 0.625rem; --sidebar: #16161e; - --sidebar-foreground: #a9b1d6; + --sidebar-foreground: #c0caf5; --sidebar-primary: #7aa2f7; --sidebar-primary-foreground: #1a1b26; - --sidebar-accent: #24283b; - --sidebar-accent-foreground: #a9b1d6; - --sidebar-border: rgba(86, 95, 137, 0.25); - --sidebar-ring: #565f89; + --sidebar-accent: #2f3654; + --sidebar-accent-foreground: #c0caf5; + --sidebar-border: rgba(139, 147, 189, 0.2); + --sidebar-ring: #7aa2f7; } /* ── One Dark ───────────────────────────────────────────────── */ .one-dark { --background: #282c34; --foreground: #abb2bf; - --card: #21252b; + --card: #2f343d; --card-foreground: #abb2bf; - --popover: #21252b; + --popover: #2f343d; --popover-foreground: #abb2bf; - --primary: #abb2bf; + --primary: #61afef; --primary-foreground: #282c34; - --secondary: #2c313a; + --secondary: #3a404b; --secondary-foreground: #abb2bf; - --surface: #21252b; - --surface-dim: #1b1f23; - --muted: #2c313a; - --muted-foreground: #5c6370; - --accent: #2c313a; + --surface: #2f343d; + --surface-dim: #21252b; + --muted: #3a404b; + --muted-foreground: #9aa3b2; + --accent: #424957; --accent-foreground: #abb2bf; --destructive: #e06c75; - --border: rgba(92, 99, 112, 0.25); - --input: rgba(92, 99, 112, 0.2); - --ring: #5c6370; + --border: rgba(154, 163, 178, 0.18); + --input: rgba(154, 163, 178, 0.14); + --ring: #61afef; --chart-1: #e06c75; --chart-2: #56b6c2; --chart-3: #98c379; @@ -394,48 +395,49 @@ --sidebar-foreground: #abb2bf; --sidebar-primary: #61afef; --sidebar-primary-foreground: #282c34; - --sidebar-accent: #2c313a; + --sidebar-accent: #424957; --sidebar-accent-foreground: #abb2bf; - --sidebar-border: rgba(92, 99, 112, 0.25); - --sidebar-ring: #5c6370; + --sidebar-border: rgba(154, 163, 178, 0.18); + --sidebar-ring: #61afef; } /* ── Gruvbox Dark ───────────────────────────────────────────── */ + .gruvbox { --background: #282828; --foreground: #ebdbb2; - --card: #1d2021; + --card: #32302f; --card-foreground: #ebdbb2; - --popover: #1d2021; + --popover: #32302f; --popover-foreground: #ebdbb2; - --primary: #ebdbb2; + --primary: #fabd2f; --primary-foreground: #282828; - --secondary: #3c3836; + --secondary: #45403d; --secondary-foreground: #ebdbb2; - --surface: #1d2021; - --surface-dim: #181a1a; - --muted: #3c3836; - --muted-foreground: #928374; - --accent: #3c3836; + --surface: #32302f; + --surface-dim: #1d2021; + --muted: #45403d; + --muted-foreground: #c4b59d; + --accent: #504945; --accent-foreground: #ebdbb2; - --destructive: #cc241d; - --border: rgba(146, 131, 116, 0.2); - --input: rgba(146, 131, 116, 0.15); - --ring: #928374; - --chart-1: #cc241d; - --chart-2: #689d6a; - --chart-3: #98971a; - --chart-4: #d79921; - --chart-5: #b16286; + --destructive: #fb4934; + --border: rgba(196, 181, 157, 0.18); + --input: rgba(196, 181, 157, 0.14); + --ring: #fabd2f; + --chart-1: #fb4934; + --chart-2: #8ec07c; + --chart-3: #b8bb26; + --chart-4: #fabd2f; + --chart-5: #d3869b; --radius: 0.625rem; --sidebar: #1d2021; --sidebar-foreground: #ebdbb2; --sidebar-primary: #fabd2f; --sidebar-primary-foreground: #282828; - --sidebar-accent: #3c3836; + --sidebar-accent: #504945; --sidebar-accent-foreground: #ebdbb2; - --sidebar-border: rgba(146, 131, 116, 0.2); - --sidebar-ring: #928374; + --sidebar-border: rgba(196, 181, 157, 0.18); + --sidebar-ring: #fabd2f; } @layer base { diff --git a/src/ui/lib/ServerStatusContext.tsx b/src/ui/lib/ServerStatusContext.tsx index 19eb15b2..7774f016 100644 --- a/src/ui/lib/ServerStatusContext.tsx +++ b/src/ui/lib/ServerStatusContext.tsx @@ -2,21 +2,20 @@ import React, { createContext, useContext, - useState, useEffect, useCallback, useRef, useMemo, + useSyncExternalStore, + useState, } from "react"; import { getAllServerStatuses, getSSHHosts } from "@/main-axios"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; - -type StatusValue = "online" | "offline" | "degraded"; - -interface ServerStatusEntry { - status: StatusValue; - lastChecked: string; -} +import { + ServerStatusStore, + type ServerStatusEntry, + type StatusValue, +} from "./server-status-store"; interface ServerStatusContextType { statuses: Map; @@ -26,6 +25,8 @@ interface ServerStatusContextType { getStatus: (hostId: number) => StatusValue; } +/** Stable for the provider lifetime — fine-grained hooks only need this. */ +const StatusStoreContext = createContext(null); const ServerStatusContext = createContext(null); const POLL_INTERVAL = 30000; @@ -37,34 +38,47 @@ export function ServerStatusProvider({ children: React.ReactNode; isAuthenticated?: boolean; }) { - const [statuses, setStatuses] = useState>( - new Map(), - ); - const [isLoading, setIsLoading] = useState(false); - const [initialLoadComplete, setInitialLoadComplete] = useState(false); - const [enabledHostIds, setEnabledHostIds] = useState>(new Set()); + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new ServerStatusStore(); + } + const store = storeRef.current; + + // Bumps only full-context consumers (dashboard, folder counts, etc.). + const [version, setVersion] = useState(0); const mountedRef = useRef(true); - const enabledHostIdsRef = useRef(enabledHostIds); + const refreshInFlightRef = useRef | null>(null); useEffect(() => { - enabledHostIdsRef.current = enabledHostIds; - }, [enabledHostIds]); + return store.subscribeAll(() => { + setVersion((v) => v + 1); + }); + }, [store]); + + useEffect(() => { + return store.subscribeMeta(() => { + setVersion((v) => v + 1); + }); + }, [store]); const fetchEnabledHosts = useCallback(async () => { if (!isAuthenticated) { + store.setEnabledHostIds(new Set()); return new Set(); } try { - const hosts = await getSSHHosts(); + const hosts = await getSSHHosts({ includeStatus: false }); const enabled = new Set(); hosts.forEach((host) => { const statsConfig = (() => { try { - return host.statsConfig - ? JSON.parse(host.statsConfig) - : DEFAULT_STATS_CONFIG; + if (!host.statsConfig) return DEFAULT_STATS_CONFIG; + if (typeof host.statsConfig === "string") { + return JSON.parse(host.statsConfig); + } + return host.statsConfig; } catch { return DEFAULT_STATS_CONFIG; } @@ -75,94 +89,122 @@ export function ServerStatusProvider({ } }); - setEnabledHostIds((prev) => { - if (prev.size !== enabled.size) return enabled; - for (const id of enabled) { - if (!prev.has(id)) return enabled; - } - return prev; - }); + store.setEnabledHostIds(enabled); return enabled; } catch { - return new Set(); + return store.getEnabledHostIds(); } - }, [isAuthenticated]); + }, [isAuthenticated, store]); const refreshStatuses = useCallback(async () => { if (!mountedRef.current || !isAuthenticated) return; - - setIsLoading(true); - try { - const data = await getAllServerStatuses(); - if (!mountedRef.current) return; - - const newStatuses = new Map(); - const now = new Date().toISOString(); - - if (data && typeof data === "object") { - Object.entries(data).forEach(([idStr, statusData]) => { - const id = parseInt(idStr, 10); - if (!isNaN(id)) { - const status = - statusData?.status === "online" ? "online" : "offline"; - newStatuses.set(id, { - status, - lastChecked: statusData?.lastChecked || now, - }); - } - }); - } - - setStatuses(newStatuses); - } catch { - if (mountedRef.current) { - setStatuses((prev) => { - const updated = new Map(prev); - enabledHostIdsRef.current.forEach((id) => { - const existing = updated.get(id); - updated.set(id, { - status: "degraded", - lastChecked: existing?.lastChecked || new Date().toISOString(), - }); - }); - return updated; - }); - } - } finally { - if (mountedRef.current) { - setIsLoading(false); - setInitialLoadComplete(true); - } + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" + ) { + return; } - }, [isAuthenticated]); - const stableEnabledHostIds = useMemo(() => enabledHostIds, [enabledHostIds]); + if (refreshInFlightRef.current) { + return refreshInFlightRef.current; + } + + const showLoading = !store.getInitialLoadComplete(); + if (showLoading) store.setLoading(true); + + const run = (async () => { + try { + const data = await getAllServerStatuses(); + if (!mountedRef.current) return; + + const newStatuses = new Map(); + const now = new Date().toISOString(); + + if (data && typeof data === "object") { + Object.entries(data).forEach(([idStr, statusData]) => { + const id = parseInt(idStr, 10); + if (!isNaN(id)) { + const status = + statusData?.status === "online" ? "online" : "offline"; + newStatuses.set(id, { + status, + lastChecked: statusData?.lastChecked || now, + }); + } + }); + } + + store.applyStatuses(newStatuses); + } catch { + if (mountedRef.current) { + store.markDegraded(store.getEnabledHostIds()); + } + } finally { + if (mountedRef.current) { + if (showLoading) store.setLoading(false); + store.setInitialLoadComplete(true); + } + } + })(); + + refreshInFlightRef.current = run.finally(() => { + refreshInFlightRef.current = null; + }); + return refreshInFlightRef.current; + }, [isAuthenticated, store]); const getStatus = useCallback( - (hostId: number): StatusValue => { - if (!stableEnabledHostIds.has(hostId)) { - return "offline"; - } - return statuses.get(hostId)?.status || "degraded"; - }, - [statuses, stableEnabledHostIds], + (hostId: number): StatusValue => store.getStatus(hostId), + [store], ); useEffect(() => { mountedRef.current = true; + let intervalId: ReturnType | null = null; + + const startPolling = () => { + if (intervalId !== null) return; + intervalId = setInterval(() => { + void refreshStatuses(); + }, POLL_INTERVAL); + }; + + const stopPolling = () => { + if (intervalId === null) return; + clearInterval(intervalId); + intervalId = null; + }; + const init = async () => { await fetchEnabledHosts(); await refreshStatuses(); + if ( + mountedRef.current && + (typeof document === "undefined" || + document.visibilityState !== "hidden") + ) { + startPolling(); + } }; - init(); + void init(); - const intervalId = setInterval(refreshStatuses, POLL_INTERVAL); + const onVisibility = () => { + if (document.visibilityState === "hidden") { + stopPolling(); + return; + } + void refreshStatuses(); + startPolling(); + }; + + document.addEventListener("visibilitychange", onVisibility); return () => { mountedRef.current = false; - clearInterval(intervalId); + stopPolling(); + document.removeEventListener("visibilitychange", onVisibility); }; }, [fetchEnabledHosts, refreshStatuses]); @@ -181,19 +223,34 @@ export function ServerStatusProvider({ }; }, [fetchEnabledHosts, refreshStatuses]); - return ( - - {children} - + const contextValue = useMemo( + () => ({ + statuses: store.getStatuses(), + isLoading: store.getIsLoading(), + initialLoadComplete: store.getInitialLoadComplete(), + refreshStatuses, + getStatus, + }), + // version refreshes statuses/isLoading/initialLoadComplete snapshots + // eslint-disable-next-line react-hooks/exhaustive-deps + [version, refreshStatuses, getStatus], ); + + return ( + + + {children} + + + ); +} + +function useStatusStore(): ServerStatusStore { + const store = useContext(StatusStoreContext); + if (!store) { + throw new Error("Server status store hooks require ServerStatusProvider"); + } + return store; } export function useServerStatus() { @@ -206,15 +263,44 @@ export function useServerStatus() { return context; } +/** + * Subscribe to a single host's status. Only re-renders when that host's + * status value changes (or its enabled flag flips). Does not re-render when + * other hosts update. + */ export function useHostStatus( hostId: number, statusCheckEnabled: boolean = true, -) { - const { getStatus } = useServerStatus(); +): StatusValue | null { + const store = useStatusStore(); + + const status = useSyncExternalStore( + (onChange) => store.subscribeHost(hostId, onChange), + () => store.getHostSnapshot(hostId), + () => store.getHostSnapshot(hostId), + ); if (!statusCheckEnabled) { - return "offline" as StatusValue; + return null; } + return status; +} - return getStatus(hostId); +/** Meta flags without depending on the full status map. */ +export function useServerStatusMeta(): { + initialLoadComplete: boolean; + isLoading: boolean; +} { + const store = useStatusStore(); + + useSyncExternalStore( + (onChange) => store.subscribeMeta(onChange), + () => store.getMetaSnapshot(), + () => store.getMetaSnapshot(), + ); + + return { + initialLoadComplete: store.getInitialLoadComplete(), + isLoading: store.getIsLoading(), + }; } diff --git a/src/ui/lib/database-transfer-url.ts b/src/ui/lib/database-transfer-url.ts new file mode 100644 index 00000000..e0f3c5fd --- /dev/null +++ b/src/ui/lib/database-transfer-url.ts @@ -0,0 +1,33 @@ +import { getBasePath } from "./base-path"; + +type BrowserLocation = Pick< + Location, + "protocol" | "host" | "hostname" | "port" +>; + +interface DatabaseTransferUrlOptions { + electron: boolean; + configuredServerUrl?: string | null; + location: BrowserLocation; +} + +export function getDatabaseTransferUrl( + operation: "export" | "import", + { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions, +): string { + if (electron) { + const serverUrl = configuredServerUrl || "http://localhost:30001"; + return `${serverUrl.replace(/\/$/, "")}/database/${operation}`; + } + + const development = + location.port === "5173" || + location.hostname === "localhost" || + location.hostname === "127.0.0.1"; + + if (development) { + return `http://localhost:30001/database/${operation}`; + } + + return `${location.protocol}//${location.host}${getBasePath()}/database/${operation}`; +} diff --git a/src/ui/lib/electron-wheel-zoom.ts b/src/ui/lib/electron-wheel-zoom.ts new file mode 100644 index 00000000..43374e2e --- /dev/null +++ b/src/ui/lib/electron-wheel-zoom.ts @@ -0,0 +1,20 @@ +import { isElectron } from "./electron"; + +export function installElectronWheelZoomGuard(): () => void { + if (!isElectron()) return () => {}; + + const preventModifierWheelZoom = (event: WheelEvent) => { + if (!event.metaKey && !event.ctrlKey) return; + event.preventDefault(); + }; + + window.addEventListener("wheel", preventModifierWheelZoom, { + capture: true, + passive: false, + }); + + return () => + window.removeEventListener("wheel", preventModifierWheelZoom, { + capture: true, + }); +} diff --git a/src/ui/lib/hosts-request-cache.ts b/src/ui/lib/hosts-request-cache.ts new file mode 100644 index 00000000..3b073ffa --- /dev/null +++ b/src/ui/lib/hosts-request-cache.ts @@ -0,0 +1,53 @@ +import type { SSHHost } from "@/types/index"; +import type { ServerStatus } from "@/main-axios"; +import { createTtlRequestCache } from "./ttl-request-cache"; + +/** Host list changes less often than status; keep a short shared window. */ +const HOSTS_TTL_MS = 10_000; +/** Status polls stack from many UI surfaces; short TTL + inflight dedupe. */ +const STATUS_TTL_MS = 3_000; + +const hostsCache = createTtlRequestCache(HOSTS_TTL_MS); +const statusCache = + createTtlRequestCache>(STATUS_TTL_MS); + +let listenersBound = false; + +function bindInvalidationListeners(): void { + if (listenersBound || typeof window === "undefined") return; + listenersBound = true; + + const invalidateHosts = () => { + hostsCache.invalidate(); + }; + + window.addEventListener("ssh-hosts:changed", invalidateHosts); + window.addEventListener("hosts:refresh", invalidateHosts); +} + +export function getCachedSSHHosts( + loader: () => Promise, +): Promise { + bindInvalidationListeners(); + return hostsCache.get(loader); +} + +export function getCachedServerStatuses( + loader: () => Promise>, +): Promise> { + return statusCache.get(loader); +} + +export function invalidateSSHHostsCache(): void { + hostsCache.invalidate(); +} + +export function invalidateServerStatusCache(): void { + statusCache.invalidate(); +} + +/** Drop both caches after host mutations so the next read is fresh. */ +export function invalidateHostsAndStatusCaches(): void { + hostsCache.invalidate(); + statusCache.invalidate(); +} diff --git a/src/ui/lib/server-status-store.ts b/src/ui/lib/server-status-store.ts new file mode 100644 index 00000000..97fa5d64 --- /dev/null +++ b/src/ui/lib/server-status-store.ts @@ -0,0 +1,184 @@ +export type StatusValue = "online" | "offline" | "degraded"; + +export interface ServerStatusEntry { + status: StatusValue; + lastChecked: string; +} + +type Listener = () => void; + +/** + * Mutable store for per-host status so list rows can subscribe to a single host + * via useSyncExternalStore instead of re-rendering the whole tree on every poll. + */ +export class ServerStatusStore { + private statuses = new Map(); + private enabledHostIds = new Set(); + private initialLoadComplete = false; + private isLoading = false; + private readonly hostListeners = new Map>(); + private readonly allListeners = new Set(); + private readonly metaListeners = new Set(); + + getStatus(hostId: number): StatusValue { + if (!this.enabledHostIds.has(hostId)) { + return "offline"; + } + return this.statuses.get(hostId)?.status || "degraded"; + } + + getStatuses(): Map { + return this.statuses; + } + + getEnabledHostIds(): Set { + return this.enabledHostIds; + } + + getInitialLoadComplete(): boolean { + return this.initialLoadComplete; + } + + getIsLoading(): boolean { + return this.isLoading; + } + + /** Snapshot string for a host — used as useSyncExternalStore getSnapshot. */ + getHostSnapshot(hostId: number): StatusValue { + return this.getStatus(hostId); + } + + getMetaSnapshot(): string { + return `${this.initialLoadComplete ? 1 : 0}:${this.isLoading ? 1 : 0}:${this.enabledHostIds.size}`; + } + + subscribeHost(hostId: number, listener: Listener): () => void { + let set = this.hostListeners.get(hostId); + if (!set) { + set = new Set(); + this.hostListeners.set(hostId, set); + } + set.add(listener); + return () => { + set!.delete(listener); + if (set!.size === 0) { + this.hostListeners.delete(hostId); + } + }; + } + + subscribeAll(listener: Listener): () => void { + this.allListeners.add(listener); + return () => { + this.allListeners.delete(listener); + }; + } + + subscribeMeta(listener: Listener): () => void { + this.metaListeners.add(listener); + return () => { + this.metaListeners.delete(listener); + }; + } + + setEnabledHostIds(next: Set): void { + if (setsEqual(this.enabledHostIds, next)) return; + const prev = this.enabledHostIds; + this.enabledHostIds = next; + this.emitMeta(); + // Hosts that entered/left the enabled set need a status re-read. + for (const id of next) { + if (!prev.has(id)) this.emitHost(id); + } + for (const id of prev) { + if (!next.has(id)) this.emitHost(id); + } + this.emitAll(); + } + + setLoading(loading: boolean): void { + if (this.isLoading === loading) return; + this.isLoading = loading; + this.emitMeta(); + this.emitAll(); + } + + setInitialLoadComplete(complete: boolean): void { + if (this.initialLoadComplete === complete) return; + this.initialLoadComplete = complete; + this.emitMeta(); + this.emitAll(); + } + + /** + * Replace status map. Notifies only hosts whose status value changed, + * plus allListeners when any change occurred. + */ + applyStatuses(next: Map): void { + const changedIds: number[] = []; + + for (const [id, entry] of next) { + const prev = this.statuses.get(id); + if (!prev || prev.status !== entry.status) { + changedIds.push(id); + } + } + for (const id of this.statuses.keys()) { + if (!next.has(id)) { + changedIds.push(id); + } + } + + if (changedIds.length === 0) { + // Still refresh lastChecked silently without notifying. + this.statuses = next; + return; + } + + this.statuses = next; + for (const id of changedIds) { + this.emitHost(id); + } + this.emitAll(); + } + + markDegraded(enabledIds: Iterable): void { + const next = new Map(this.statuses); + let changed = false; + const now = new Date().toISOString(); + for (const id of enabledIds) { + const existing = next.get(id); + if (existing?.status === "degraded") continue; + changed = true; + next.set(id, { + status: "degraded", + lastChecked: existing?.lastChecked || now, + }); + } + if (changed) { + this.applyStatuses(next); + } + } + + private emitHost(hostId: number): void { + const set = this.hostListeners.get(hostId); + if (!set) return; + for (const listener of set) listener(); + } + + private emitAll(): void { + for (const listener of this.allListeners) listener(); + } + + private emitMeta(): void { + for (const listener of this.metaListeners) listener(); + } +} + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const id of a) { + if (!b.has(id)) return false; + } + return true; +} diff --git a/src/ui/lib/terminal-syntax-highlighter.ts b/src/ui/lib/terminal-syntax-highlighter.ts index a1479f70..b576514e 100644 --- a/src/ui/lib/terminal-syntax-highlighter.ts +++ b/src/ui/lib/terminal-syntax-highlighter.ts @@ -61,6 +61,8 @@ const MAX_LINE_LENGTH = 2000; // (mc, nano, vim, htop). If a chunk contains these, highlighting can inject // extra SGR bytes into a full-screen redraw and corrupt xterm's cursor state. const TUI_SEQUENCE = /\x1b\[(?:[\d;]*[ABCDEFGHJKST]|\?[\d;]*[hl])/; +const TUI_FRAME_SEQUENCE = /[\u2500-\u257f]|\x1b[()][0B]|\x1b\[[0-9;]*[~`]/; +const CONTROL_STRING_SEQUENCE = /\x1b[\]P^_]/; // A bare \r (not immediately followed by \n) means the terminal is overwriting // the current line (shell prompts, progress bars). Highlighting mid-rewrite @@ -376,6 +378,7 @@ function highlightLine( const bare = cr ? line.slice(0, -1) : line; if (!bare.trim()) return line; + if (bare.length > MAX_LINE_LENGTH) return line; if (isShellPromptLine(bare)) return line; const segments = parseAnsiSegments(bare); @@ -398,6 +401,8 @@ export function highlightTerminalOutput( if (hasIncompleteAnsiSequence(text)) return text; if (TUI_SEQUENCE.test(text)) return text; + if (TUI_FRAME_SEQUENCE.test(text)) return text; + if (CONTROL_STRING_SEQUENCE.test(text)) return text; if (MID_LINE_CR.test(text)) return text; const activePatterns = buildActivePatterns(options); diff --git a/src/ui/lib/ttl-request-cache.ts b/src/ui/lib/ttl-request-cache.ts new file mode 100644 index 00000000..971c34ef --- /dev/null +++ b/src/ui/lib/ttl-request-cache.ts @@ -0,0 +1,48 @@ +/** + * Short-lived in-memory cache with in-flight request deduplication. + * Concurrent callers share one loader promise; failures are not cached. + */ +export type TtlRequestCache = { + get(loader: () => Promise): Promise; + invalidate(): void; + peek(): T | null; +}; + +export function createTtlRequestCache(ttlMs: number): TtlRequestCache { + let cached: { value: T; expiresAt: number } | null = null; + let inflight: Promise | null = null; + + return { + get(loader: () => Promise): Promise { + if (cached && Date.now() < cached.expiresAt) { + return Promise.resolve(cached.value); + } + if (inflight) { + return inflight; + } + + inflight = (async () => { + try { + const value = await loader(); + cached = { value, expiresAt: Date.now() + ttlMs }; + return value; + } finally { + inflight = null; + } + })(); + + return inflight; + }, + + invalidate(): void { + cached = null; + }, + + peek(): T | null { + if (cached && Date.now() < cached.expiresAt) { + return cached.value; + } + return null; + }, + }; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 0dec6796..9e33a4ab 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -106,8 +106,265 @@ "filterTagsGroup": "Tags" }, "homepage": { - "failedToLoadAlerts": "Failed to load alerts", - "failedToDismissAlert": "Failed to dismiss alert" + "title": "Homepage", + "addWidget": "Add Widget", + "editWidget": "Edit Widget", + "deleteWidget": "Delete", + "widgetTypes": "Widget Types", + "serviceLink": "Service Link", + "folder": "Folder", + "clock": "Clock", + "notes": "Notes", + "hostStatus": "Host Status", + "bookmarkList": "Bookmarks", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out", + "resetView": "Reset View", + "lockLayout": "Lock Layout", + "unlockLayout": "Unlock Layout", + "noWidgets": "Right-click or click + to add your first widget", + "openFullView": "Open Full View", + "previewTitle": "Homepage Preview", + "cancel": "Cancel", + "save": "Save", + "title_label": "Title", + "widgetTitlePlaceholder": "Widget title (optional)", + "url": "URL", + "imageUrl": "Custom Image URL", + "imageUrlHint": "Leave blank to use the site favicon automatically", + "showImage": "Show Image", + "description": "Description", + "color": "Color", + "icon": "Icon", + "expanded": "Expanded by default", + "timezone": "Timezone", + "showSeconds": "Show seconds", + "format12h": "12-hour", + "format24h": "24-hour", + "content": "Content", + "backgroundColor": "Background Color", + "host": "Host", + "showMetrics": "Show Metrics", + "links": "Links", + "addLink": "Add Link", + "linkLabel": "Label", + "linkUrl": "URL", + "removeLink": "Remove", + "categoryLinks": "Links", + "categoryInfo": "Info", + "categorySystem": "System", + "widgetServiceLinkDesc": "A clickable tile linking to a service URL", + "widgetFolderDesc": "A container to group related widgets", + "widgetClockDesc": "A live clock with configurable timezone", + "widgetNotesDesc": "A markdown notes widget", + "widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host", + "widgetBookmarkListDesc": "A list of quick links", + "copyLink": "Copy Link", + "linkCopied": "Link copied!", + "location": "Location", + "temperatureUnit": "Temperature Unit", + "showForecast": "Show 3-day forecast", + "scrolling": "Allow Scrolling", + "feedUrl": "Feed URL", + "maxItems": "Max Items", + "showDescription": "Show Description", + "widgetWeatherName": "Weather", + "widgetWeatherDesc": "Live weather for any location", + "widgetIframeName": "iFrame Embed", + "widgetIframeDesc": "Embed any URL in an iframe", + "widgetRssName": "RSS Feed", + "widgetRssDesc": "Display items from an RSS or Atom feed", + "dragToFolder": "Drag widgets here or use + to add", + "showDisk": "Show Disk Usage", + "addToFolder": "Add widget to folder", + "noHostSelected": "No host selected", + "metricsNotAvailable": "Metrics not available", + "selectHost": "Select a host...", + "displayedMetrics": "Displayed Metrics", + "metricCpu": "CPU", + "metricMemory": "Memory", + "metricDisk": "Disk", + "metricUptime": "Uptime", + "metricSystem": "System", + "metricOs": "OS", + "metricKernel": "Kernel", + "metricHostname": "Hostname", + "metricNetwork": "Network", + "metricProcesses": "Processes", + "metricProcessesTotal": "total", + "metricProcessesRunning": "running", + "categoryMonitoring": "Monitoring", + "loading": "Loading...", + "noData": "No data available", + "allClear": "All clear", + "acknowledgeAlert": "Acknowledge", + "noPingUrls": "No URLs configured", + "pingLabel": "Label", + "addPingUrl": "Add URL", + "showLatency": "Show Latency", + "refreshInterval": "Refresh Interval", + "seconds": "seconds", + "filterActivityTypes": "Filter Types", + "filterTypesHint": "Leave empty to show all", + "showTimestamp": "Show Timestamp", + "uptimeUnavailable": "Uptime unavailable", + "uptimeLabel": "Uptime", + "overviewVersion": "Version", + "overviewUpdate": "Up to date", + "overviewUpdateAvailable": "Update available", + "overviewDatabase": "Database", + "overviewUptime": "Uptime", + "noHosts": "No hosts configured", + "hostGridHosts": "Hosts", + "hostGridAllHint": "Leave empty to show all hosts", + "columns": "Columns", + "showIp": "Show IP Address", + "connectionType": "Connection Type", + "layout": "Layout", + "showStatus": "Show Status", + "showHostName": "Show Host Name", + "noDockerActivity": "No Docker activity", + "noActivity": "No activity", + "showAcknowledged": "Show Acknowledged", + "showCurrentValue": "Show Current Value", + "chartMetric": "Metric", + "metricRange": "Range", + "widgetMetricsChartName": "Metrics Chart", + "widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host", + "widgetHostGridName": "Host Grid", + "widgetHostGridDesc": "Grid view of SSH host statuses", + "widgetAlertFeedName": "Alert Feed", + "widgetAlertFeedDesc": "Live alert firings with acknowledge support", + "widgetPingStatusName": "Ping Status", + "widgetPingStatusDesc": "HTTP ping status for one or more URLs", + "widgetRecentActivityName": "Recent Activity", + "widgetRecentActivityDesc": "Scrollable feed of recent Termix activity", + "widgetTermixUptimeName": "Termix Uptime", + "widgetTermixUptimeDesc": "Live uptime counter for the Termix server", + "widgetSystemOverviewName": "System Overview", + "widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance", + "widgetSshQuickConnectName": "SSH Quick Connect", + "widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions", + "widgetDockerActivityName": "Docker Activity", + "widgetDockerActivityDesc": "Recent Docker container events across all hosts", + "widgetCalendarName": "Calendar", + "widgetCalendarDesc": "A monthly calendar with today highlighted", + "widgetCountdownName": "Countdown", + "widgetCountdownDesc": "Countdown timer to a target date", + "widgetSearchBarName": "Search Bar", + "widgetSearchBarDesc": "Quick web search widget", + "widgetTextBannerName": "Text Banner", + "widgetTextBannerDesc": "A bold label or section header for the canvas", + "widgetImageWidgetName": "Image", + "widgetImageWidgetDesc": "Display an image from a URL", + "widgetMarkdownNotesName": "Markdown Notes", + "widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering", + "widgetCustomApiName": "Custom API", + "widgetCustomApiDesc": "Fetch and display data from any JSON API", + "widgetServiceGridName": "Service Grid", + "widgetServiceGridDesc": "A configurable grid of service tile links", + "widgetDashboardLinksName": "Dashboard Links", + "widgetDashboardLinksDesc": "Display your configured service links from the dashboard", + "widgetSearchLinksName": "Search Shortcuts", + "widgetSearchLinksDesc": "Quick search shortcut buttons with inline input", + "widgetLinkTreeName": "Link Tree", + "widgetLinkTreeDesc": "Grouped sections of links with headings", + "calMon": "Mo", + "calTue": "Tu", + "calWed": "We", + "calThu": "Th", + "calFri": "Fr", + "calSat": "Sa", + "calSun": "Su", + "startOnMonday": "Start week on Monday", + "countdownNoDate": "No target date set", + "countdownPast": "Event has passed", + "countdownDays": "days", + "countdownHours": "hrs", + "countdownMinutes": "min", + "countdownSeconds": "sec", + "countdownLabel": "Label", + "countdownLabelPlaceholder": "e.g. Launch Day", + "countdownShowDays": "Show Days", + "countdownShowHours": "Show Hours", + "targetDate": "Target Date", + "searchEngine": "Search Engine", + "customSearchUrl": "Custom Search URL", + "searchPlaceholder": "Search...", + "searchGo": "Go", + "searchPlaceholderLabel": "Placeholder Text", + "searchPlaceholderHint": "Text shown inside the search input", + "openInNewTab": "Open in New Tab", + "searchQueryPlaceholder": "Enter query...", + "noSearchShortcuts": "No shortcuts configured", + "addSearchShortcut": "Add Shortcut", + "fontSize": "Font Size", + "textAlign": "Text Align", + "fontWeight": "Font Weight", + "clearColor": "Clear Color", + "imageFit": "Image Fit", + "imageLinkUrl": "Link URL", + "noImage": "No image URL set", + "altText": "Alt Text", + "altTextPlaceholder": "Describe the image", + "renderMarkdown": "Render Markdown", + "displayMode": "Display Mode", + "displayField": "Display Field", + "jsonPath": "JSON Path", + "customApiLabel": "Label", + "customApiLabelPlaceholder": "e.g. Temperature", + "customApiUnit": "Unit", + "customApiNoUrl": "No API URL configured", + "customApiError": "Failed to fetch", + "customApiNotArray": "Response is not an array", + "addService": "Add Service", + "showLabels": "Show Labels", + "iconSize": "Icon Size", + "noDashboardLinks": "No dashboard links configured", + "noLimit": "No limit", + "sectionHeading": "Section Heading", + "addSection": "Add Section", + "compactMode": "Compact Mode", + "showDetailed": "Show Detailed", + "selectHosts": "Select Hosts", + "allHosts": "All hosts", + "listLayout": "List", + "gridLayout": "Grid", + "terminal": "Terminal", + "files": "Files", + "docker": "Docker", + "range15m": "15 minutes", + "range1h": "1 hour", + "range6h": "6 hours", + "range24h": "24 hours", + "metricNetRx": "Net Download", + "metricNetTx": "Net Upload", + "severityFilter": "Severity Filter", + "filterAll": "All", + "accentColor": "Accent Color", + "widgetSshTerminalName": "SSH Terminal", + "widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host", + "sshTerminalNoHost": "No host configured", + "sshTerminalConnect": "Connect", + "sshTerminalAutoConnect": "Auto-connect on load", + "widgetQuickConnectName": "Quick Connect", + "widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts", + "connectionTypes": "Connection Types", + "connType_terminal": "Terminal", + "connType_files": "File Manager", + "connType_docker": "Docker", + "connType_tunnel": "Tunnel", + "connType_host-metrics": "Host Metrics", + "connType_rdp": "RDP", + "connType_vnc": "VNC", + "connType_telnet": "Telnet", + "widgetFileManagerName": "File Manager", + "widgetFileManagerDesc": "Embedded SFTP file manager for a configured host", + "widgetDockerName": "Docker Manager", + "widgetDockerDesc": "Embedded Docker container manager for a configured host", + "widgetTunnelName": "Tunnel Manager", + "widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host", + "widgetNoHostSelected": "No host configured" }, "serverConfig": { "title": "Server Configuration", @@ -317,7 +574,7 @@ "status": "Status", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", - "movedToFolder": "Moved to \"{{folder}}\"", + "movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"", "editHostTooltip": "Edit host", "statusChecks": "Status Checks", "metricsCollection": "Metrics Collection", @@ -607,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Comma-separated name patterns that enable Docker for matching guests", "proxmoxPreferredRanges": "Preferred IP ranges", "proxmoxPreferredRangesDesc": "Comma-separated prefixes in priority order for IP selection when a guest has multiple interfaces", + "proxmoxAutoSync": "Auto sync guests", + "proxmoxAutoSyncDesc": "Periodically discover this Proxmox node and create or update imported guest hosts while your session is unlocked.", + "proxmoxSyncInterval": "Sync interval (minutes)", + "proxmoxSyncIntervalDesc": "Minimum 5 minutes. The scheduler skips locked user sessions.", + "proxmoxMarkMissing": "Mark missing guests", + "proxmoxMarkMissingDesc": "Add a proxmox-missing tag when a previously imported guest disappears instead of deleting it.", + "proxmoxLastSync": "Last sync", + "proxmoxLastSyncNever": "Not synced yet", + "proxmoxLastSyncNoResult": "No result details", + "proxmoxLastSyncSummary": "{{created}} created, {{updated}} updated, {{markedMissing}} missing, {{skipped}} skipped", + "proxmoxLastSyncStatus": { + "success": "Success", + "error": "Failed", + "pending": "Pending" + }, "proxmoxDiscoverAction": "Discover & import Proxmox guests", "proxmoxImportTitle": "Import from Proxmox", "proxmoxSelectHost": "Select a Proxmox host…", @@ -741,7 +1013,6 @@ "failedToSaveFolder": "Failed to save folder", "folderDeleted": "Deleted folder \"{{name}}\"", "deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.", - "movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"", "failedToMoveHosts": "Failed to move hosts", "expandAll": "Expand all folders", "collapseAll": "Collapse all folders", @@ -791,7 +1062,6 @@ "failedToDeployKey": "Failed to deploy key", "deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.", "movedToRoot": "Moved to root", - "failedToMoveHosts": "Failed to move hosts", "enableTerminalFeature": "Enable Terminal", "disableTerminalFeature": "Disable Terminal", "enableFilesFeature": "Enable Files", @@ -912,7 +1182,57 @@ "shareHost": "Share Host", "shareHostTitle": "Share: {{name}}", "sharing": { - "requiresCredential": "This host must use a credential to enable sharing. Edit the host and assign a credential first." + "loadError": "Failed to load sharing data. Please try again.", + "shareWithSection": "Share with", + "usersTab": "Users", + "rolesTab": "Roles", + "searchPlaceholder": "Search users or roles...", + "noMatches": "No matches found", + "permissionLevelLabel": "Permission level", + "levels": { + "connect": { + "label": "Connect", + "description": "Open sessions only: terminal, remote desktop, file manager, tunnels and Docker. No access to the host configuration." + }, + "view": { + "label": "View", + "description": "Connect, plus see the host configuration. Secrets are never shown." + }, + "edit": { + "label": "Edit", + "description": "View, plus modify the host. Secrets can be replaced but never read; credential assignments stay owner-only." + }, + "manage": { + "label": "Manage", + "description": "Edit, plus share the host with others, change permission levels and revoke access." + } + }, + "expiryLabel": "Access expiry", + "expiry": { + "never": "Never", + "oneHour": "1 hour", + "oneDay": "24 hours", + "sevenDays": "7 days", + "thirtyDays": "30 days", + "custom": "Custom" + }, + "customHoursPlaceholder": "Hours until access expires", + "shareButton": "Share", + "shareWithCount": "Share ({{count}})", + "currentAccess": "Current access", + "noAccessEntries": "This host has not been shared yet", + "grantedBy": "Granted by", + "expires": "Expires", + "expired": "Expired", + "never": "Never", + "revoke": "Revoke", + "accessUpdated": "Access updated", + "accessUpdateFailed": "Failed to update access", + "sharedBadge": "Shared", + "sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)", + "viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.", + "sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.", + "ownerOnlyControl": "Only the host owner can change this" }, "guac": { "connection": "Connection", @@ -1047,28 +1367,10 @@ "backspaceKey": "Backspace Key", "saveHostFirst": "Save the host first.", "sharingOptionsAfterSave": "Sharing options are available after the host has been saved.", - "sharingLoadError": "Failed to load sharing data. Check your connection and try again.", - "shareHostSection": "Share Host", - "shareWithUser": "Share with User", - "shareWithRole": "Share with Role", - "selectUser": "Select User", - "selectRole": "Select Role", - "selectUserOption": "Select a user...", - "selectRoleOption": "Select a role...", "permissionLevel": "Permission Level", - "expiresInHours": "Expires in (hours)", - "noExpiryPlaceholder": "Leave empty for no expiry", - "shareBtn": "Share", - "currentAccess": "Current Access", "typeHeader": "Type", "targetHeader": "Target", "permissionHeader": "Permission", - "grantedByHeader": "Granted By", - "expiresHeader": "Expires", - "noAccessEntries": "No access entries yet.", - "expiredLabel": "Expired", - "neverLabel": "Never", - "revokeBtn": "Revoke", "cancelBtn": "Cancel", "savingBtn": "Saving...", "updateHostBtn": "Update Host", @@ -1935,7 +2237,8 @@ "passwordLoginDisabledDesc": "Password login is disabled. Use a passkey or an external authentication provider.", "signInWithPasskey": "Sign in with passkey", "passkeyLoginFailed": "Passkey login failed", - "attemptsRemaining": "{{count}} attempts remaining" + "attemptsRemaining": "{{count}} attempts remaining", + "confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?" }, "hostKey": { "verifyNewHost": "Verify SSH Host Key", @@ -2068,6 +2371,13 @@ "serviceLinks": "Service Links", "homepagePreview": "Homepage Preview" }, + "donation": { + "title": "Enjoying Termix?", + "body": "Termix is free and open source, built and maintained by a two-person team in our spare time. If it's replaced a commercial tool you'd otherwise be paying for, a donation helps cover hosting costs and keeps development going. As of now, donations are crypto only.", + "milestones": "Donations also help fund the time to research and learn what's needed to build features like SAML and Kubernetes support. See the progress on the donate page.", + "cta": "Donate", + "dismiss": "Maybe later" + }, "dashboardTab": { "stable": "STABLE", "hostsOnline": "Hosts Online", @@ -2535,6 +2845,71 @@ "updateAdminStatusFailed": "Failed to update admin status", "allSessionsRevoked": "All sessions revoked", "revokeSessionsFailed": "Failed to revoke sessions", + "manageUserData": "Manage user data", + "backToUsers": "Back to users", + "manageTabAccount": "Account", + "manageTabHosts": "Hosts", + "manageTabCredentials": "Credentials", + "manageTabSnippets": "Snippets", + "manageTabSessions": "Sessions", + "manageTabDanger": "Danger", + "manageEditorBack": "Back to {{username}}'s data", + "dataLockedBadge": "LOCKED", + "dataLockedNotice": "{{username}}'s data stays locked until they next log in. Their hosts, credentials and snippets cannot be viewed or edited until then.", + "resetPasswordTitle": "Reset Password", + "resetPasswordOidcOnly": "This user signs in through an external provider and has no password.", + "resetPasswordPlaceholder": "New password", + "resetPasswordBtn": "Reset", + "resetPasswordWorking": "Resetting...", + "resetPasswordSuccess": "Password reset", + "resetPasswordSuccessWiped": "Password reset. The user's encrypted data was wiped.", + "resetPasswordFailed": "Failed to reset password", + "resetPasswordConfirmWipe": "{{username}} has not logged in since the encryption upgrade, so their data cannot be recovered. Resetting now will delete their hosts, credentials and snippets. Continue?", + "totpSectionTitle": "Two-Factor Authentication", + "totpStatusEnabled": "TOTP is enabled for this user", + "totpStatusDisabled": "TOTP is not enabled for this user", + "disableTotp": "Disable", + "disableTotpConfirm": "Disable two-factor authentication for {{username}}? They will be able to log in with only their password.", + "totpDisabledSuccess": "TOTP disabled", + "totpDisableFailed": "Failed to disable TOTP", + "manageApiKeys": "API Keys", + "apiKeyNamePlaceholder": "Key name", + "apiKeyCopyNotice": "Copy this key now, it won't be shown again.", + "noApiKeysForUser": "No API keys", + "apiKeyDeleteFailed": "Failed to delete API key", + "exportUserData": "Data Export", + "exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.", + "exportUserDataSuccess": "User data exported", + "exportUserDataFailed": "Failed to export user data", + "hostsCount": "{{count}} hosts", + "addHostForUser": "Add Host", + "noHostsForUser": "This user has no hosts", + "connectToHost": "Connect", + "deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?", + "hostDeletedSuccess": "Host deleted", + "hostDeleteFailed": "Failed to delete host", + "credentialsCount": "{{count}} credentials", + "addCredentialForUser": "Add Credential", + "noCredentialsForUser": "This user has no credentials", + "deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?", + "credentialDeletedSuccess": "Credential deleted", + "credentialDeleteFailed": "Failed to delete credential", + "snippetsCount": "{{count}} snippets", + "addSnippetForUser": "Add Snippet", + "noSnippetsForUser": "This user has no snippets", + "snippetRequiredFields": "Snippet name and content are required", + "snippetNamePlaceholder": "Snippet name", + "snippetContentPlaceholder": "Command content", + "snippetFolderPlaceholder": "Folder (optional)", + "snippetSaved": "Snippet saved", + "snippetSaveFailed": "Failed to save snippet", + "deleteSnippetConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?", + "snippetDeletedSuccess": "Snippet deleted", + "snippetDeleteFailed": "Failed to delete snippet", + "noSessionsForUser": "No active sessions", + "deleteUserDangerDesc": "Permanently delete {{username}} and all of their data (hosts, credentials, snippets, history). This cannot be undone.", + "deleteUserConfirm": "Permanently delete {{username}} and all of their data?", + "deleteUserAdminBlocked": "Remove admin status before deleting this user.", "createRoleRequired": "Name and display name are required", "createRoleSuccess": "Role \"{{name}}\" created", "createRoleFailed": "Failed to create role", @@ -2567,7 +2942,16 @@ "hostDefaultsCommandHistory": "Command History", "hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default", "hostDefaultsSaved": "Host defaults saved", - "hostDefaultsSaveFailed": "Failed to save host defaults" + "hostDefaultsSaveFailed": "Failed to save host defaults", + "rolePermissions": { + "count": "{{count}} permissions", + "editAction": "Edit permissions", + "loadError": "Failed to load the permissions catalog", + "saved": "Role permissions saved", + "saveError": "Failed to save role permissions", + "save": "Save", + "saving": "Saving..." + } }, "newUi": { "sidebar": { @@ -2736,6 +3120,10 @@ "executionFailed": "Failed" }, "userProfile": { + "donateTitle": "Keep Termix alive", + "donateDescription": "Termix is free, self-hosted, and built by only a few people in their spare time. If it's saved you time or money, a crypto donation helps keep it going.", + "donateMilestones": "Donations help fund the time to research and learn what's needed to build SAML, Kubernetes, and Agent support. See the progress and donate.", + "donateButton": "Donate crypto", "storageModeLocal": "Browser", "storageModeCloud": "Database", "storageModeDescription": "Browser stores settings in this browser only. Database syncs to the server and loads on any device.", @@ -2756,6 +3144,10 @@ "twoFaOn": "On", "twoFaOff": "Off", "versionLabel": "Version", + "betaProgramTitle": "Beta Program", + "betaProgramDescription": "Try upcoming features early with the weekly rolling :beta Docker tag. Unstable, not for production.", + "betaProgramFeedback": "Found a bug? Report it here.", + "betaProgramLearnMore": "Learn More", "deleteAccount": "Delete Account", "deleteAccountDescription": "Permanently delete your account", "changeServerDescription": "Switch to a different Termix backend server", @@ -3076,266 +3468,5 @@ "channels": "Notification Channels", "noChannelsHint": "Add channels in the Channels tab first", "ruleSaveFailed": "Failed to save rule" - }, - "homepage": { - "title": "Homepage", - "addWidget": "Add Widget", - "editWidget": "Edit Widget", - "deleteWidget": "Delete", - "widgetTypes": "Widget Types", - "serviceLink": "Service Link", - "folder": "Folder", - "clock": "Clock", - "notes": "Notes", - "hostStatus": "Host Status", - "bookmarkList": "Bookmarks", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "resetView": "Reset View", - "lockLayout": "Lock Layout", - "unlockLayout": "Unlock Layout", - "noWidgets": "Right-click or click + to add your first widget", - "openFullView": "Open Full View", - "previewTitle": "Homepage Preview", - "cancel": "Cancel", - "save": "Save", - "title_label": "Title", - "widgetTitlePlaceholder": "Widget title (optional)", - "url": "URL", - "imageUrl": "Custom Image URL", - "imageUrlHint": "Leave blank to use the site favicon automatically", - "showImage": "Show Image", - "description": "Description", - "color": "Color", - "icon": "Icon", - "expanded": "Expanded by default", - "timezone": "Timezone", - "showSeconds": "Show seconds", - "format12h": "12-hour", - "format24h": "24-hour", - "content": "Content", - "backgroundColor": "Background Color", - "host": "Host", - "showMetrics": "Show Metrics", - "links": "Links", - "addLink": "Add Link", - "linkLabel": "Label", - "linkUrl": "URL", - "removeLink": "Remove", - "categoryLinks": "Links", - "categoryInfo": "Info", - "categorySystem": "System", - "widgetServiceLinkDesc": "A clickable tile linking to a service URL", - "widgetFolderDesc": "A container to group related widgets", - "widgetClockDesc": "A live clock with configurable timezone", - "widgetNotesDesc": "A markdown notes widget", - "widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host", - "widgetBookmarkListDesc": "A list of quick links", - "copyLink": "Copy Link", - "linkCopied": "Link copied!", - "location": "Location", - "temperatureUnit": "Temperature Unit", - "showForecast": "Show 3-day forecast", - "scrolling": "Allow Scrolling", - "feedUrl": "Feed URL", - "maxItems": "Max Items", - "showDescription": "Show Description", - "widgetWeatherName": "Weather", - "widgetWeatherDesc": "Live weather for any location", - "widgetIframeName": "iFrame Embed", - "widgetIframeDesc": "Embed any URL in an iframe", - "widgetRssName": "RSS Feed", - "widgetRssDesc": "Display items from an RSS or Atom feed", - "dragToFolder": "Drag widgets here or use + to add", - "showDisk": "Show Disk Usage", - "addToFolder": "Add widget to folder", - "noHostSelected": "No host selected", - "metricsNotAvailable": "Metrics not available", - "selectHost": "Select a host...", - "displayedMetrics": "Displayed Metrics", - "metricCpu": "CPU", - "metricMemory": "Memory", - "metricDisk": "Disk", - "metricUptime": "Uptime", - "metricSystem": "System", - "metricOs": "OS", - "metricKernel": "Kernel", - "metricHostname": "Hostname", - "metricNetwork": "Network", - "metricProcesses": "Processes", - "metricProcessesTotal": "total", - "metricProcessesRunning": "running", - "categoryMonitoring": "Monitoring", - "loading": "Loading...", - "noData": "No data available", - "allClear": "All clear", - "acknowledgeAlert": "Acknowledge", - "noPingUrls": "No URLs configured", - "pingLabel": "Label", - "addPingUrl": "Add URL", - "showLatency": "Show Latency", - "refreshInterval": "Refresh Interval", - "seconds": "seconds", - "filterActivityTypes": "Filter Types", - "filterTypesHint": "Leave empty to show all", - "showTimestamp": "Show Timestamp", - "uptimeUnavailable": "Uptime unavailable", - "uptimeLabel": "Uptime", - "overviewVersion": "Version", - "overviewUpdate": "Up to date", - "overviewUpdateAvailable": "Update available", - "overviewDatabase": "Database", - "overviewUptime": "Uptime", - "noHosts": "No hosts configured", - "hostGridHosts": "Hosts", - "hostGridAllHint": "Leave empty to show all hosts", - "columns": "Columns", - "showIp": "Show IP Address", - "connectionType": "Connection Type", - "layout": "Layout", - "showStatus": "Show Status", - "showHostName": "Show Host Name", - "noDockerActivity": "No Docker activity", - "noActivity": "No activity", - "showAcknowledged": "Show Acknowledged", - "showCurrentValue": "Show Current Value", - "chartMetric": "Metric", - "metricRange": "Range", - "widgetMetricsChartName": "Metrics Chart", - "widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host", - "widgetHostGridName": "Host Grid", - "widgetHostGridDesc": "Grid view of SSH host statuses", - "widgetAlertFeedName": "Alert Feed", - "widgetAlertFeedDesc": "Live alert firings with acknowledge support", - "widgetPingStatusName": "Ping Status", - "widgetPingStatusDesc": "HTTP ping status for one or more URLs", - "widgetRecentActivityName": "Recent Activity", - "widgetRecentActivityDesc": "Scrollable feed of recent Termix activity", - "widgetTermixUptimeName": "Termix Uptime", - "widgetTermixUptimeDesc": "Live uptime counter for the Termix server", - "widgetSystemOverviewName": "System Overview", - "widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance", - "widgetSshQuickConnectName": "SSH Quick Connect", - "widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions", - "widgetDockerActivityName": "Docker Activity", - "widgetDockerActivityDesc": "Recent Docker container events across all hosts", - "widgetCalendarName": "Calendar", - "widgetCalendarDesc": "A monthly calendar with today highlighted", - "widgetCountdownName": "Countdown", - "widgetCountdownDesc": "Countdown timer to a target date", - "widgetSearchBarName": "Search Bar", - "widgetSearchBarDesc": "Quick web search widget", - "widgetTextBannerName": "Text Banner", - "widgetTextBannerDesc": "A bold label or section header for the canvas", - "widgetImageWidgetName": "Image", - "widgetImageWidgetDesc": "Display an image from a URL", - "widgetMarkdownNotesName": "Markdown Notes", - "widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering", - "widgetCustomApiName": "Custom API", - "widgetCustomApiDesc": "Fetch and display data from any JSON API", - "widgetServiceGridName": "Service Grid", - "widgetServiceGridDesc": "A configurable grid of service tile links", - "widgetDashboardLinksName": "Dashboard Links", - "widgetDashboardLinksDesc": "Display your configured service links from the dashboard", - "widgetSearchLinksName": "Search Shortcuts", - "widgetSearchLinksDesc": "Quick search shortcut buttons with inline input", - "widgetLinkTreeName": "Link Tree", - "widgetLinkTreeDesc": "Grouped sections of links with headings", - "calMon": "Mo", - "calTue": "Tu", - "calWed": "We", - "calThu": "Th", - "calFri": "Fr", - "calSat": "Sa", - "calSun": "Su", - "startOnMonday": "Start week on Monday", - "countdownNoDate": "No target date set", - "countdownPast": "Event has passed", - "countdownDays": "days", - "countdownHours": "hrs", - "countdownMinutes": "min", - "countdownSeconds": "sec", - "countdownLabel": "Label", - "countdownLabelPlaceholder": "e.g. Launch Day", - "countdownShowDays": "Show Days", - "countdownShowHours": "Show Hours", - "targetDate": "Target Date", - "searchEngine": "Search Engine", - "customSearchUrl": "Custom Search URL", - "searchPlaceholder": "Search...", - "searchGo": "Go", - "searchPlaceholderLabel": "Placeholder Text", - "searchPlaceholderHint": "Text shown inside the search input", - "openInNewTab": "Open in New Tab", - "searchQueryPlaceholder": "Enter query...", - "noSearchShortcuts": "No shortcuts configured", - "addSearchShortcut": "Add Shortcut", - "fontSize": "Font Size", - "textAlign": "Text Align", - "fontWeight": "Font Weight", - "clearColor": "Clear Color", - "imageFit": "Image Fit", - "imageLinkUrl": "Link URL", - "noImage": "No image URL set", - "altText": "Alt Text", - "altTextPlaceholder": "Describe the image", - "renderMarkdown": "Render Markdown", - "displayMode": "Display Mode", - "displayField": "Display Field", - "jsonPath": "JSON Path", - "customApiLabel": "Label", - "customApiLabelPlaceholder": "e.g. Temperature", - "customApiUnit": "Unit", - "customApiNoUrl": "No API URL configured", - "customApiError": "Failed to fetch", - "customApiNotArray": "Response is not an array", - "addService": "Add Service", - "showLabels": "Show Labels", - "iconSize": "Icon Size", - "noDashboardLinks": "No dashboard links configured", - "noLimit": "No limit", - "sectionHeading": "Section Heading", - "addSection": "Add Section", - "compactMode": "Compact Mode", - "showDetailed": "Show Detailed", - "selectHosts": "Select Hosts", - "allHosts": "All hosts", - "listLayout": "List", - "gridLayout": "Grid", - "terminal": "Terminal", - "files": "Files", - "docker": "Docker", - "range15m": "15 minutes", - "range1h": "1 hour", - "range6h": "6 hours", - "range24h": "24 hours", - "metricNetRx": "Net Download", - "metricNetTx": "Net Upload", - "severityFilter": "Severity Filter", - "filterAll": "All", - "accentColor": "Accent Color", - "widgetSshTerminalName": "SSH Terminal", - "widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host", - "sshTerminalNoHost": "No host configured", - "sshTerminalConnect": "Connect", - "sshTerminalAutoConnect": "Auto-connect on load", - "widgetQuickConnectName": "Quick Connect", - "widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts", - "connectionTypes": "Connection Types", - "connType_terminal": "Terminal", - "connType_files": "File Manager", - "connType_docker": "Docker", - "connType_tunnel": "Tunnel", - "connType_host-metrics": "Host Metrics", - "connType_rdp": "RDP", - "connType_vnc": "VNC", - "connType_telnet": "Telnet", - "widgetFileManagerName": "File Manager", - "widgetFileManagerDesc": "Embedded SFTP file manager for a configured host", - "widgetDockerName": "Docker Manager", - "widgetDockerDesc": "Embedded Docker container manager for a configured host", - "widgetTunnelName": "Tunnel Manager", - "widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host", - "widgetNoHostSelected": "No host configured" } } diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index 5273a3f0..c1cc6651 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Komma-geskeide naampatrone wat Docker in staat stel om gaste te pas", "proxmoxPreferredRanges": "Voorkeur-IP-reekse", "proxmoxPreferredRangesDesc": "Komma-geskeide voorvoegsels in prioriteitsvolgorde vir IP-keuse wanneer 'n gas verskeie koppelvlakke het", + "proxmoxAutoSync": "Outomatiese sinkronisering van gaste", + "proxmoxAutoSyncDesc": "Ontdek hierdie Proxmox-nodus gereeld en skep of werk ingevoerde gashere op terwyl jou sessie ontsluit is.", + "proxmoxSyncInterval": "Sinkronisasie-interval (minute)", + "proxmoxSyncIntervalDesc": "Minimum 5 minute. Die skeduleerder slaan geslote gebruikerssessies oor.", + "proxmoxMarkMissing": "Merk ontbrekende gaste", + "proxmoxMarkMissingDesc": "Voeg 'n proxmox-missing-etiket by wanneer 'n voorheen ingevoerde gas verdwyn in plaas daarvan om dit te verwyder.", + "proxmoxLastSync": "Laaste sinkronisasie", + "proxmoxLastSyncNever": "Nog nie gesinkroniseer nie", + "proxmoxLastSyncNoResult": "Geen resultaatbesonderhede nie", + "proxmoxLastSyncSummary": "{{created}} geskep, {{updated}} opgedateer, {{markedMissing}} ontbreek, {{skipped}} oorgeslaan", + "proxmoxLastSyncStatus": { + "success": "Sukses", + "error": "Misluk", + "pending": "Hangende" + }, "proxmoxDiscoverAction": "Ontdek en invoer Proxmox-gaste", "proxmoxImportTitle": "Invoer vanaf Proxmox", "proxmoxSelectHost": "Kies 'n Proxmox-gasheer…", @@ -1167,7 +1182,57 @@ "shareHost": "Deel gasheer", "shareHostTitle": "Deel: {{name}}", "sharing": { - "requiresCredential": "Hierdie gasheer moet 'n geloofsbrief gebruik om deling te aktiveer. Wysig die gasheer en ken eers 'n geloofsbrief toe." + "loadError": "Kon nie deeldata laai nie. Probeer asseblief weer.", + "shareWithSection": "Deel met", + "usersTab": "Gebruikers", + "rolesTab": "Rolle", + "searchPlaceholder": "Soek gebruikers of rolle...", + "noMatches": "Geen ooreenstemmings gevind nie", + "permissionLevelLabel": "Toestemmingsvlak", + "levels": { + "connect": { + "label": "Verbind", + "description": "Slegs oop sessies: terminaal, afstandwerkskerm, lêerbestuurder, tonnels en Docker. Geen toegang tot die gasheerkonfigurasie nie." + }, + "view": { + "label": "Bekyk", + "description": "Verbind, plus sien die gasheerkonfigurasie. Geheime word nooit gewys nie." + }, + "edit": { + "label": "Wysig", + "description": "Bekyk, plus wysig die gasheer. Geheime kan vervang word, maar nooit gelees word nie; geloofsbriewe-toewysings bly slegs vir die eienaar." + }, + "manage": { + "label": "Bestuur", + "description": "Wysig, plus deel die gasheer met ander, verander toestemmingsvlakke en herroep toegang." + } + }, + "expiryLabel": "Toegang verval", + "expiry": { + "never": "Nooit", + "oneHour": "1 uur", + "oneDay": "24 uur", + "sevenDays": "7 dae", + "thirtyDays": "30 dae", + "custom": "Pasgemaak" + }, + "customHoursPlaceholder": "Ure totdat toegang verval", + "shareButton": "Deel", + "shareWithCount": "Deel ({{count}})", + "currentAccess": "Huidige toegang", + "noAccessEntries": "Hierdie gasheer is nog nie gedeel nie", + "grantedBy": "Toegeken deur", + "expires": "Vervaldatum", + "expired": "Verstryk", + "never": "Nooit", + "revoke": "Herroep", + "accessUpdated": "Toegang opgedateer", + "accessUpdateFailed": "Kon nie toegang opdateer nie", + "sharedBadge": "Gedeel", + "sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}} toegang)", + "viewOnlyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}} met kyktoegang. Die konfigurasie is slegs leesbaar.", + "sharedEditBanner": "Hierdie gasheer word met jou gedeel deur {{owner}} met wysigingstoegang. Veranderinge is van toepassing op die werklike gasheer; verifikasieverwysings kan slegs deur die eienaar verander word.", + "ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander" }, "guac": { "connection": "Verbinding", @@ -1302,28 +1367,10 @@ "backspaceKey": "Terugspasie-sleutel", "saveHostFirst": "Stoor eers die gasheer.", "sharingOptionsAfterSave": "Deelopsies is beskikbaar nadat die gasheer gestoor is.", - "sharingLoadError": "Kon nie deeldata laai nie. Gaan jou verbinding na en probeer weer.", - "shareHostSection": "Deel gasheer", - "shareWithUser": "Deel met Gebruiker", - "shareWithRole": "Deel met Rol", - "selectUser": "Kies Gebruiker", - "selectRole": "Kies Rol", - "selectUserOption": "Kies 'n gebruiker...", - "selectRoleOption": "Kies 'n rol...", "permissionLevel": "Toestemmingsvlak", - "expiresInHours": "Verval oor (ure)", - "noExpiryPlaceholder": "Los leeg vir geen vervaldatum", - "shareBtn": "Deel", - "currentAccess": "Huidige Toegang", "typeHeader": "Tipe", "targetHeader": "Teiken", "permissionHeader": "Toestemming", - "grantedByHeader": "Toegeken deur", - "expiresHeader": "Vervaldatum", - "noAccessEntries": "Geen toegangsinskrywings nog nie.", - "expiredLabel": "Verstryk", - "neverLabel": "Nooit", - "revokeBtn": "Herroep", "cancelBtn": "Kanselleer", "savingBtn": "Stoor...", "updateHostBtn": "Opdateer gasheer", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Wagwoordaanmelding is gedeaktiveer. Gebruik 'n wagwoordsleutel of 'n eksterne verifikasieverskaffer.", "signInWithPasskey": "Teken aan met wagwoord", "passkeyLoginFailed": "Wagwoord-aanmelding het misluk", - "attemptsRemaining": "{{count}} pogings oor" + "attemptsRemaining": "{{count}} pogings oor", + "confirmResetDataWipe": "Hierdie rekening het nie aangemeld sedert die enkripsie-opgradering nie, dus kan die gestoorde data nie sonder die ou wagwoord herstel word nie. Terugstelling sal die gashere, geloofsbriewe en brokkies permanent verwyder. Gaan voort?" }, "hostKey": { "verifyNewHost": "Verifieer SSH-gasheersleutel", @@ -2323,6 +2371,13 @@ "serviceLinks": "Diens skakels", "homepagePreview": "Tuisbladvoorskou" }, + "donation": { + "title": "Geniet jy Termix?", + "body": "Termix is gratis en oopbron, gebou en onderhou deur 'n tweepersoonspan in ons vrye tyd. As dit 'n kommersiële hulpmiddel vervang waarvoor jy andersins sou betaal, help 'n skenking om gasheerkoste te dek en ontwikkeling aan die gang te hou. Van nou af is skenkings slegs kripto.", + "milestones": "Donasies help ook om die tyd te befonds om navorsing te doen en te leer wat nodig is om funksies soos SAML en Kubernetes-ondersteuning te bou. Sien die vordering op die skenkingsbladsy.", + "cta": "Skenk", + "dismiss": "Miskien later" + }, "dashboardTab": { "stable": "STABIEL", "hostsOnline": "Aanlyn gashere", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Kon nie administrateurstatus opdateer nie", "allSessionsRevoked": "Alle sessies herroep", "revokeSessionsFailed": "Kon nie sessies herroep nie", + "manageUserData": "Bestuur gebruikersdata", + "backToUsers": "Terug na gebruikers", + "manageTabAccount": "Rekening", + "manageTabHosts": "Gashere", + "manageTabCredentials": "Geloofsbriewe", + "manageTabSnippets": "Brokkies", + "manageTabSessions": "Sessies", + "manageTabDanger": "Gevaar", + "manageEditorBack": "Terug na {{username}}se data", + "dataLockedBadge": "GESLUIT", + "dataLockedNotice": "{{username}}se data bly gesluit totdat hulle weer aanmeld. Hul gashere, geloofsbriewe en brokkies kan nie tot dan besigtig of geredigeer word nie.", + "resetPasswordTitle": "Herstel wagwoord", + "resetPasswordOidcOnly": "Hierdie gebruiker meld aan deur 'n eksterne verskaffer en het geen wagwoord nie.", + "resetPasswordPlaceholder": "Nuwe wagwoord", + "resetPasswordBtn": "Herstel", + "resetPasswordWorking": "Herstel tans...", + "resetPasswordSuccess": "Wagwoord herstel", + "resetPasswordSuccessWiped": "Wagwoord herstel. Die gebruiker se geïnkripteerde data is uitgevee.", + "resetPasswordFailed": "Kon nie wagwoord terugstel nie", + "resetPasswordConfirmWipe": "{{username}} het nie aangemeld sedert die enkripsie-opgradering nie, dus kan hul data nie herwin word nie. As u nou terugstel, sal hul gashere, geloofsbriewe en brokkies uitgevee word. Gaan voort?", + "totpSectionTitle": "Twee-faktor-verifikasie", + "totpStatusEnabled": "TOTP is vir hierdie gebruiker geaktiveer", + "totpStatusDisabled": "TOTP is nie vir hierdie gebruiker geaktiveer nie", + "disableTotp": "Deaktiveer", + "disableTotpConfirm": "Deaktiveer tweefaktor-verifikasie vir {{username}}? Hulle sal slegs met hul wagwoord kan aanmeld.", + "totpDisabledSuccess": "TOTP gedeaktiveer", + "totpDisableFailed": "Kon nie TOTP deaktiveer nie", + "manageApiKeys": "API-sleutels", + "apiKeyNamePlaceholder": "Sleutelnaam", + "apiKeyCopyNotice": "Kopieer hierdie sleutel nou, dit sal nie weer gewys word nie.", + "noApiKeysForUser": "Geen API-sleutels nie", + "apiKeyDeleteFailed": "Kon nie API-sleutel verwyder nie", + "exportUserData": "Data-uitvoer", + "exportUserDataDesc": "Laai hierdie gebruiker se gashere, geloofsbriewe en lêerbestuurderdata as JSON af. Geheime word ontsyfer.", + "exportUserDataSuccess": "Gebruikersdata uitgevoer", + "exportUserDataFailed": "Kon nie gebruikersdata uitvoer nie", + "hostsCount": "{{count}} gashere", + "addHostForUser": "Voeg gasheer by", + "noHostsForUser": "Hierdie gebruiker het geen gashere nie", + "connectToHost": "Verbind", + "deleteHostConfirm": "Vee gasheer \"{{name}}\" uit wat aan {{username}} behoort?", + "hostDeletedSuccess": "Gasheer uitgevee", + "hostDeleteFailed": "Kon nie gasheer verwyder nie", + "credentialsCount": "{{count}} geloofsbriewe", + "addCredentialForUser": "Voeg geloofsbriewe by", + "noCredentialsForUser": "Hierdie gebruiker het geen geloofsbriewe nie", + "deleteCredentialConfirm": "Vee die geloofsbrief \"{{name}}\" wat aan {{username}} behoort, uit?", + "credentialDeletedSuccess": "Geloofsbrief uitgevee", + "credentialDeleteFailed": "Kon nie geloofsbriewe verwyder nie", + "snippetsCount": "{{count}} brokkies", + "addSnippetForUser": "Voeg brokkie by", + "noSnippetsForUser": "Hierdie gebruiker het geen brokkies nie", + "snippetRequiredFields": "Brokkienaam en inhoud word vereis", + "snippetNamePlaceholder": "Brokkie naam", + "snippetContentPlaceholder": "Opdraginhoud", + "snippetFolderPlaceholder": "Vouer (opsioneel)", + "snippetSaved": "Brokkie gestoor", + "snippetSaveFailed": "Kon nie die brokkie stoor nie", + "deleteSnippetConfirm": "Vee die brokkie \"{{name}}\" uit wat aan {{username}} behoort?", + "snippetDeletedSuccess": "Brokkie uitgevee", + "snippetDeleteFailed": "Kon nie fragment uitvee nie", + "noSessionsForUser": "Geen aktiewe sessies nie", + "deleteUserDangerDesc": "Vee {{username}} en al hul data (gashere, geloofsbriewe, brokkies, geskiedenis) permanent uit. Dit kan nie ongedaan gemaak word nie.", + "deleteUserConfirm": "Verwyder {{username}} en al hul data permanent?", + "deleteUserAdminBlocked": "Verwyder administrateurstatus voordat hierdie gebruiker uitgevee word.", "createRoleRequired": "Naam en vertoonnaam word vereis", "createRoleSuccess": "Rol \"{{name}}\" geskep", "createRoleFailed": "Kon nie rol skep nie", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Opdraggeskiedenis", "hostDefaultsCommandHistoryDesc": "Volg standaard opdraggeskiedenis op nuwe gashere", "hostDefaultsSaved": "Gasheerverstekwaardes gestoor", - "hostDefaultsSaveFailed": "Kon nie gasheerverstekwaardes stoor nie" + "hostDefaultsSaveFailed": "Kon nie gasheerverstekwaardes stoor nie", + "rolePermissions": { + "count": "{{count}} toestemmings", + "editAction": "Wysig toestemmings", + "loadError": "Kon nie die toestemmingskatalogus laai nie", + "saved": "Roltoestemmings gestoor", + "saveError": "Kon nie roltoestemmings stoor nie", + "save": "Stoor", + "saving": "Stoor..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Misluk" }, "userProfile": { + "donateTitle": "Hou Termix lewendig", + "donateDescription": "Termix is gratis, self-gehost en gebou deur slegs 'n paar mense in hul vrye tyd. As dit jou tyd of geld bespaar het, help 'n kripto-skenking om dit aan die gang te hou.", + "donateMilestones": "Donasies help om die tyd te befonds om navorsing te doen en te leer wat nodig is om SAML-, Kubernetes- en Agent-ondersteuning te bou. Sien die vordering en skenk.", + "donateButton": "Skenk kripto", "storageModeLocal": "Blaaier", "storageModeCloud": "Databasis", "storageModeDescription": "Die blaaier stoor instellings slegs in hierdie blaaier. Die databasis sinkroniseer met die bediener en laai op enige toestel.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Aan", "twoFaOff": "Af", "versionLabel": "Weergawe", + "betaProgramTitle": "Beta-program", + "betaProgramDescription": "Probeer komende funksies vroegtydig met die weeklikse rollende :beta Docker-etiket. Onstabiel, nie vir produksie nie.", + "betaProgramFeedback": "Het jy 'n fout gevind? Rapporteer dit hier.", + "betaProgramLearnMore": "Leer Meer", "deleteAccount": "Vee rekening uit", "deleteAccountDescription": "Vee jou rekening permanent uit", "changeServerDescription": "Skakel oor na 'n ander Termix-agterbediener", diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index aaece5dc..2b023682 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "أنماط أسماء مفصولة بفواصل تُمكّن Docker من مطابقة الضيوف", "proxmoxPreferredRanges": "نطاقات IP المفضلة", "proxmoxPreferredRangesDesc": "بادئات مفصولة بفواصل مرتبة حسب الأولوية لاختيار عنوان IP عندما يكون لدى جهاز ضيف عدة واجهات", + "proxmoxAutoSync": "مزامنة الضيوف تلقائيًا", + "proxmoxAutoSyncDesc": "قم باكتشاف عقدة Proxmox هذه بشكل دوري وقم بإنشاء أو تحديث مضيفي الضيوف المستوردين أثناء فتح جلستك.", + "proxmoxSyncInterval": "فاصل المزامنة (بالدقائق)", + "proxmoxSyncIntervalDesc": "الحد الأدنى 5 دقائق. يتخطى المجدول جلسات المستخدمين المقفلة.", + "proxmoxMarkMissing": "مارك الضيوف المفقودين", + "proxmoxMarkMissingDesc": "أضف علامة proxmox-missing عندما يختفي ضيف تم استيراده مسبقًا بدلاً من حذفه.", + "proxmoxLastSync": "آخر مزامنة", + "proxmoxLastSyncNever": "لم تتم المزامنة بعد", + "proxmoxLastSyncNoResult": "لا توجد تفاصيل عن النتائج", + "proxmoxLastSyncSummary": "تم إنشاء {{created}} ، تم تحديث {{updated}} ، مفقود {{markedMissing}} ، تم تخطي {{skipped}}", + "proxmoxLastSyncStatus": { + "success": "نجاح", + "error": "فشل", + "pending": "قيد الانتظار" + }, "proxmoxDiscoverAction": "اكتشف واستورد ضيوف Proxmox", "proxmoxImportTitle": "الاستيراد من Proxmox", "proxmoxSelectHost": "حدد مضيف Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "مشاركة الاستضافة", "shareHostTitle": "مشاركة: {{name}}", "sharing": { - "requiresCredential": "يجب أن يستخدم هذا المضيف بيانات اعتماد لتمكين المشاركة. قم بتحرير المضيف وتعيين بيانات اعتماد له أولاً." + "loadError": "فشل تحميل بيانات المشاركة. يرجى المحاولة مرة أخرى.", + "shareWithSection": "شارك مع", + "usersTab": "المستخدمون", + "rolesTab": "الأدوار", + "searchPlaceholder": "ابحث عن المستخدمين أو الأدوار...", + "noMatches": "لم يتم العثور على نتائج", + "permissionLevelLabel": "مستوى الإذن", + "levels": { + "connect": { + "label": "يتصل", + "description": "الجلسات المفتوحة فقط: الطرفية، سطح المكتب البعيد، مدير الملفات، الأنفاق، وDocker. لا يُسمح بالوصول إلى إعدادات المضيف." + }, + "view": { + "label": "منظر", + "description": "اتصل، واطلع على إعدادات المضيف. لا يتم عرض أي بيانات سرية." + }, + "edit": { + "label": "يحرر", + "description": "يمكنك عرض المضيف وتعديله. يمكن استبدال البيانات السرية ولكن لا يمكن قراءتها مطلقًا؛ وتبقى بيانات الاعتماد خاصة بالمالك فقط." + }, + "manage": { + "label": "يدير", + "description": "قم بالتحرير، بالإضافة إلى مشاركة المضيف مع الآخرين، وتغيير مستويات الأذونات وإلغاء الوصول." + } + }, + "expiryLabel": "انتهاء صلاحية الوصول", + "expiry": { + "never": "أبداً", + "oneHour": "ساعة واحدة", + "oneDay": "24 ساعة", + "sevenDays": "7 أيام", + "thirtyDays": "30 يومًا", + "custom": "مخصص" + }, + "customHoursPlaceholder": "عدد الساعات المتبقية حتى انتهاء صلاحية الوصول", + "shareButton": "يشارك", + "shareWithCount": "مشاركة ({{count}})", + "currentAccess": "الوصول الحالي", + "noAccessEntries": "لم تتم مشاركة هذا المضيف بعد", + "grantedBy": "مُنِحَتْ بِـ", + "expires": "تاريخ الانتهاء", + "expired": "منتهي الصلاحية", + "never": "أبداً", + "revoke": "إلغاء", + "accessUpdated": "تم تحديث الوصول", + "accessUpdateFailed": "فشل تحديث الوصول", + "sharedBadge": "مشترك", + "sharedBadgeTooltip": "تمت المشاركة بواسطة {{owner}} ({{level}} الوصول)", + "viewOnlyBanner": "هذا المضيف مُشترك معك بواسطة {{owner}} مع إمكانية عرض الملف. الإعدادات للقراءة فقط.", + "sharedEditBanner": "هذا المضيف مُشترك معك بواسطة {{owner}} مع إمكانية التعديل. تُطبّق التغييرات على المضيف الفعلي؛ ولا يُمكن تغيير بيانات المصادقة إلا من قِبل المالك.", + "ownerOnlyControl": "لا يمكن تغيير هذا إلا من قبل مالك المضيف" }, "guac": { "connection": "اتصال", @@ -1302,28 +1367,10 @@ "backspaceKey": "مفتاح الحذف", "saveHostFirst": "احفظ المضيف أولاً.", "sharingOptionsAfterSave": "تتوفر خيارات المشاركة بعد حفظ المضيف.", - "sharingLoadError": "فشل تحميل بيانات المشاركة. تحقق من اتصالك وحاول مرة أخرى.", - "shareHostSection": "مشاركة الاستضافة", - "shareWithUser": "مشاركة مع المستخدم", - "shareWithRole": "مشاركة مع الدور", - "selectUser": "حدد المستخدم", - "selectRole": "اختر الدور", - "selectUserOption": "اختر مستخدمًا...", - "selectRoleOption": "اختر دورًا...", "permissionLevel": "مستوى الإذن", - "expiresInHours": "ينتهي خلال (ساعات)", - "noExpiryPlaceholder": "اتركه فارغًا لعدم وجود تاريخ انتهاء صلاحية", - "shareBtn": "يشارك", - "currentAccess": "الوصول الحالي", "typeHeader": "يكتب", "targetHeader": "هدف", "permissionHeader": "إذن", - "grantedByHeader": "مُنِحَتْ بِجِهِ", - "expiresHeader": "تاريخ الانتهاء", - "noAccessEntries": "لا توجد إدخالات وصول حتى الآن.", - "expiredLabel": "منتهي الصلاحية", - "neverLabel": "أبداً", - "revokeBtn": "إلغاء", "cancelBtn": "يلغي", "savingBtn": "توفير...", "updateHostBtn": "تحديث المضيف", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "تم تعطيل تسجيل الدخول باستخدام كلمة المرور. استخدم رمز مرور أو موفر مصادقة خارجي.", "signInWithPasskey": "سجّل الدخول باستخدام رمز المرور", "passkeyLoginFailed": "فشل تسجيل الدخول باستخدام مفتاح المرور", - "attemptsRemaining": "عدد المحاولات المتبقية: {{count}}" + "attemptsRemaining": "عدد المحاولات المتبقية: {{count}}", + "confirmResetDataWipe": "لم يتم تسجيل الدخول إلى هذا الحساب منذ تحديث التشفير، لذا لا يمكن استعادة بياناته المخزنة بدون كلمة المرور القديمة. ستؤدي إعادة التعيين إلى حذف مضيفيه وبيانات اعتماده ومقتطفاته نهائيًا. هل تريد المتابعة؟" }, "hostKey": { "verifyNewHost": "تحقق من مفتاح مضيف SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "روابط الخدمة", "homepagePreview": "معاينة الصفحة الرئيسية" }, + "donation": { + "title": "هل تستمتع باستخدام Termix؟", + "body": "Termix برنامج مجاني ومفتوح المصدر، تم تطويره وصيانته بواسطة فريق مكون من شخصين في أوقات فراغهم. إذا حلّ Termix محل أداة تجارية كنت ستدفع ثمنها، فإن تبرعك يساعد في تغطية تكاليف الاستضافة واستمرار التطوير. حاليًا، التبرعات متاحة بالعملات الرقمية فقط.", + "milestones": "تساهم التبرعات أيضاً في تمويل الوقت اللازم للبحث والتعلم اللازمين لتطوير ميزات مثل دعم SAML وKubernetes. تابعوا التقدم المحرز على صفحة التبرعات.", + "cta": "يتبرع", + "dismiss": "ربما لاحقاً" + }, "dashboardTab": { "stable": "مستقر", "hostsOnline": "المضيفون عبر الإنترنت", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "فشل تحديث حالة المسؤول", "allSessionsRevoked": "تم إلغاء جميع الجلسات", "revokeSessionsFailed": "فشل في إلغاء الجلسات", + "manageUserData": "إدارة بيانات المستخدم", + "backToUsers": "العودة إلى المستخدمين", + "manageTabAccount": "حساب", + "manageTabHosts": "المضيفون", + "manageTabCredentials": "أوراق اعتماد", + "manageTabSnippets": "مقتطفات", + "manageTabSessions": "الجلسات", + "manageTabDanger": "خطر", + "manageEditorBack": "العودة إلى بيانات {{username}}", + "dataLockedBadge": "مغلق", + "dataLockedNotice": "تبقى بيانات المستخدم {{username}}مقفلة حتى يقوم بتسجيل الدخول مرة أخرى. ولا يمكن عرض أو تعديل مضيفيه وبيانات اعتماده ومقتطفاته حتى ذلك الحين.", + "resetPasswordTitle": "إعادة تعيين كلمة المرور", + "resetPasswordOidcOnly": "يقوم هذا المستخدم بتسجيل الدخول من خلال مزود خدمة خارجي وليس لديه كلمة مرور.", + "resetPasswordPlaceholder": "كلمة المرور الجديدة", + "resetPasswordBtn": "إعادة ضبط", + "resetPasswordWorking": "جارٍ إعادة الضبط...", + "resetPasswordSuccess": "إعادة تعيين كلمة المرور", + "resetPasswordSuccessWiped": "تمت إعادة تعيين كلمة المرور. تم مسح بيانات المستخدم المشفرة.", + "resetPasswordFailed": "فشل إعادة تعيين كلمة المرور", + "resetPasswordConfirmWipe": "لم يسجّل المستخدم {{username}} دخوله منذ تحديث التشفير، لذا لا يمكن استعادة بياناته. إعادة التعيين الآن ستحذف مضيفيه وبيانات اعتماده وبياناته الشخصية. هل تريد المتابعة؟", + "totpSectionTitle": "المصادقة الثنائية", + "totpStatusEnabled": "تم تفعيل رمز TOTP لهذا المستخدم", + "totpStatusDisabled": "لم يتم تفعيل رمز التحقق من المصدر (TOTP) لهذا المستخدم", + "disableTotp": "إبطال", + "disableTotpConfirm": "هل يجب تعطيل المصادقة الثنائية للمستخدم {{username}}؟ سيتمكنون من تسجيل الدخول باستخدام كلمة المرور فقط.", + "totpDisabledSuccess": "تم تعطيل رمز التحقق من الهاتف", + "totpDisableFailed": "فشل تعطيل بروتوكول TOTP", + "manageApiKeys": "مفاتيح واجهة برمجة التطبيقات", + "apiKeyNamePlaceholder": "الاسم الرئيسي", + "apiKeyCopyNotice": "انسخ هذا المفتاح الآن، فلن يظهر مرة أخرى.", + "noApiKeysForUser": "لا توجد مفاتيح API", + "apiKeyDeleteFailed": "فشل حذف مفتاح API", + "exportUserData": "تصدير البيانات", + "exportUserDataDesc": "قم بتنزيل بيانات المضيفين وبيانات الاعتماد وبيانات مدير الملفات الخاصة بهذا المستخدم بصيغة JSON. يتم فك تشفير البيانات السرية.", + "exportUserDataSuccess": "تم تصدير بيانات المستخدم", + "exportUserDataFailed": "فشل تصدير بيانات المستخدم", + "hostsCount": "{{count}} المضيفون", + "addHostForUser": "إضافة مضيف", + "noHostsForUser": "لا يملك هذا المستخدم أي مضيفين", + "connectToHost": "يتصل", + "deleteHostConfirm": "حذف المضيف \"{{name}}\" التابع لـ \" {{username}}\"؟", + "hostDeletedSuccess": "تم حذف المضيف", + "hostDeleteFailed": "فشل حذف المضيف", + "credentialsCount": "بيانات الاعتماد {{count}}", + "addCredentialForUser": "إضافة بيانات اعتماد", + "noCredentialsForUser": "لا يملك هذا المستخدم بيانات اعتماد", + "deleteCredentialConfirm": "حذف بيانات الاعتماد \"{{name}}\" التابعة لـ \" {{username}}\"؟", + "credentialDeletedSuccess": "تم حذف بيانات الاعتماد", + "credentialDeleteFailed": "فشل حذف بيانات الاعتماد", + "snippetsCount": "مقتطفات {{count}}", + "addSnippetForUser": "إضافة مقتطف", + "noSnippetsForUser": "لا يملك هذا المستخدم أي مقتطفات", + "snippetRequiredFields": "اسم المقتطف ومحتواه مطلوبان", + "snippetNamePlaceholder": "اسم المقتطف", + "snippetContentPlaceholder": "محتوى الأمر", + "snippetFolderPlaceholder": "مجلد (اختياري)", + "snippetSaved": "تم حفظ المقتطف", + "snippetSaveFailed": "فشل حفظ المقتطف", + "deleteSnippetConfirm": "حذف المقتطف \"{{name}}\" التابع لـ {{username}}؟", + "snippetDeletedSuccess": "تم حذف المقتطف", + "snippetDeleteFailed": "فشل حذف المقتطف", + "noSessionsForUser": "لا توجد جلسات نشطة", + "deleteUserDangerDesc": "احذف نهائيًا {{username}} وجميع بياناتها (المضيفين، بيانات الاعتماد، المقتطفات، السجل). لا يمكن التراجع عن هذا الإجراء.", + "deleteUserConfirm": "هل تريد حذف {{username}} وجميع بياناتهم بشكل دائم؟", + "deleteUserAdminBlocked": "قم بإزالة صلاحيات المسؤول قبل حذف هذا المستخدم.", "createRoleRequired": "الاسم واسم العرض مطلوبان", "createRoleSuccess": "تم إنشاء الدور \"{{name}}\"", "createRoleFailed": "فشل إنشاء الدور", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "سجل الأوامر", "hostDefaultsCommandHistoryDesc": "يتم تتبع سجل الأوامر على الأجهزة المضيفة الجديدة افتراضيًا", "hostDefaultsSaved": "تم حفظ الإعدادات الافتراضية للمضيف", - "hostDefaultsSaveFailed": "فشل حفظ الإعدادات الافتراضية للمضيف" + "hostDefaultsSaveFailed": "فشل حفظ الإعدادات الافتراضية للمضيف", + "rolePermissions": { + "count": "{{count}} الأذونات", + "editAction": "تعديل الأذونات", + "loadError": "فشل تحميل كتالوج الأذونات", + "saved": "تم حفظ أذونات الدور", + "saveError": "فشل حفظ أذونات الدور", + "save": "يحفظ", + "saving": "توفير..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "فشل" }, "userProfile": { + "donateTitle": "حافظ على حياة Termix", + "donateDescription": "Termix مجاني، ويُستضاف ذاتيًا، وقد طُوّر بواسطة عدد قليل من الأشخاص في أوقات فراغهم. إذا وفّر لك الوقت أو المال، فإن التبرع بالعملات الرقمية يُساعد في استمراره.", + "donateMilestones": "تساهم التبرعات في تمويل الوقت اللازم للبحث والتعلم حول ما يلزم لبناء دعم SAML وKubernetes وAgent. تابعوا التقدم وتبرعوا.", + "donateButton": "تبرع بالعملات الرقمية", "storageModeLocal": "المتصفح", "storageModeCloud": "قاعدة البيانات", "storageModeDescription": "يخزن المتصفح الإعدادات في هذا المتصفح فقط. تتم مزامنة قاعدة البيانات مع الخادم ويتم تحميلها على أي جهاز.", @@ -3011,6 +3144,10 @@ "twoFaOn": "على", "twoFaOff": "عن", "versionLabel": "إصدار", + "betaProgramTitle": "برنامج تجريبي", + "betaProgramDescription": "جرّب الميزات القادمة مبكراً باستخدام علامة Docker :beta الأسبوعية المتجددة. غير مستقر، غير مناسب للاستخدام في بيئات الإنتاج.", + "betaProgramFeedback": "هل وجدت خطأً برمجياً؟ أبلغ عنه هنا.", + "betaProgramLearnMore": "يتعلم أكثر", "deleteAccount": "حذف الحساب", "deleteAccountDescription": "احذف حسابك نهائياً", "changeServerDescription": "قم بالتبديل إلى خادم Termix خلفي مختلف", diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index dd9d937a..c1dd527c 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Модели за имена, разделени със запетаи, които позволяват на Docker да съпоставя гости", "proxmoxPreferredRanges": "Предпочитани IP диапазони", "proxmoxPreferredRangesDesc": "Префикси, разделени със запетаи, подредени по приоритет за избор на IP адрес, когато гост има множество интерфейси", + "proxmoxAutoSync": "Автоматично синхронизиране на гости", + "proxmoxAutoSyncDesc": "Периодично откривайте този Proxmox възел и създавайте или актуализирайте импортирани гост хостове, докато сесията ви е отключена.", + "proxmoxSyncInterval": "Интервал на синхронизиране (минути)", + "proxmoxSyncIntervalDesc": "Минимум 5 минути. Планировчикът пропуска заключените потребителски сесии.", + "proxmoxMarkMissing": "Маркиране на липсващи гости", + "proxmoxMarkMissingDesc": "Добавете етикет proxmox-missing, когато предварително импортиран гост изчезне, вместо да го изтриете.", + "proxmoxLastSync": "Последна синхронизация", + "proxmoxLastSyncNever": "Все още не е синхронизирано", + "proxmoxLastSyncNoResult": "Няма подробности за резултата", + "proxmoxLastSyncSummary": "{{created}} създадено, {{updated}} актуализирано, {{markedMissing}} липсващо, {{skipped}} пропуснато", + "proxmoxLastSyncStatus": { + "success": "Успех", + "error": "Неуспешно", + "pending": "В очакване" + }, "proxmoxDiscoverAction": "Откриване и импортиране на гости на Proxmox", "proxmoxImportTitle": "Импортиране от Proxmox", "proxmoxSelectHost": "Изберете Proxmox хост…", @@ -1167,7 +1182,57 @@ "shareHost": "Споделяне на хост", "shareHostTitle": "Споделяне: {{name}}", "sharing": { - "requiresCredential": "Този хост трябва да използва идентификационни данни, за да активира споделянето. Първо редактирайте хоста и задайте идентификационни данни." + "loadError": "Зареждането на данните за споделяне не бе успешно. Моля, опитайте отново.", + "shareWithSection": "Сподели с", + "usersTab": "Потребители", + "rolesTab": "Роли", + "searchPlaceholder": "Търсене на потребители или роли...", + "noMatches": "Няма намерени съвпадения", + "permissionLevelLabel": "Ниво на разрешение", + "levels": { + "connect": { + "label": "Свържете се", + "description": "Само отворени сесии: терминал, отдалечен работен плот, файлов мениджър, тунели и Docker. Няма достъп до конфигурацията на хоста." + }, + "view": { + "label": "Преглед", + "description": "Свържете се, плюс вижте конфигурацията на хоста. Тайните никога не се показват." + }, + "edit": { + "label": "Редактиране", + "description": "Преглеждайте и променяйте хоста. Тайните могат да бъдат заменени, но никога прочетени; присвояванията на идентификационни данни остават само за собственика." + }, + "manage": { + "label": "Управление", + "description": "Редактирайте, плюс споделяйте хоста с други, променяйте нивата на разрешения и отменяйте достъпа." + } + }, + "expiryLabel": "Изтичане на достъпа", + "expiry": { + "never": "Никога", + "oneHour": "1 час", + "oneDay": "24 часа", + "sevenDays": "7 дни", + "thirtyDays": "30 дни", + "custom": "Персонализирано" + }, + "customHoursPlaceholder": "Часове до изтичане на срока на достъп", + "shareButton": "Споделяне", + "shareWithCount": "Споделяне ({{count}})", + "currentAccess": "Текущ достъп", + "noAccessEntries": "Този хост все още не е споделен", + "grantedBy": "Предоставено от", + "expires": "Изтича", + "expired": "Изтекъл", + "never": "Никога", + "revoke": "Отмяна", + "accessUpdated": "Достъпът е актуализиран", + "accessUpdateFailed": "Актуализирането на достъпа не бе успешно", + "sharedBadge": "Споделено", + "sharedBadgeTooltip": "Споделено от {{owner}} (достъп{{level}})", + "viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп за преглед. Конфигурацията е само за четене.", + "sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се отнасят за истинския хост; референтните данни за удостоверяване могат да бъдат променяни само от собственика.", + "ownerOnlyControl": "Само собственикът на хоста може да промени това" }, "guac": { "connection": "Връзка", @@ -1302,28 +1367,10 @@ "backspaceKey": "Клавиш Backspace", "saveHostFirst": "Първо запазете хоста.", "sharingOptionsAfterSave": "Опциите за споделяне са налични след запазване на хоста.", - "sharingLoadError": "Зареждането на данните за споделяне не бе успешно. Проверете връзката си и опитайте отново.", - "shareHostSection": "Споделяне на хост", - "shareWithUser": "Споделяне с потребителя", - "shareWithRole": "Споделяне с роля", - "selectUser": "Изберете потребител", - "selectRole": "Изберете роля", - "selectUserOption": "Изберете потребител...", - "selectRoleOption": "Изберете роля...", "permissionLevel": "Ниво на разрешение", - "expiresInHours": "Изтича след (часове)", - "noExpiryPlaceholder": "Оставете празно за дата без срок на валидност", - "shareBtn": "Споделяне", - "currentAccess": "Текущ достъп", "typeHeader": "Тип", "targetHeader": "Цел", "permissionHeader": "Разрешение", - "grantedByHeader": "Предоставено от", - "expiresHeader": "Изтича", - "noAccessEntries": "Все още няма записи за достъп.", - "expiredLabel": "Изтекъл", - "neverLabel": "Никога", - "revokeBtn": "Отмяна", "cancelBtn": "Отказ", "savingBtn": "Запазване...", "updateHostBtn": "Актуализиране на хоста", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Входът с парола е деактивиран. Използвайте ключ за достъп или външен доставчик на удостоверяване.", "signInWithPasskey": "Влезте с парола", "passkeyLoginFailed": "Влизането с парола не бе успешно", - "attemptsRemaining": "{{count}} attempts remaining" + "attemptsRemaining": "{{count}} attempts remaining", + "confirmResetDataWipe": "Този акаунт не е влизал след надстройката на криптирането, така че съхранените в него данни не могат да бъдат възстановени без старата парола. Нулирането ще изтрие окончателно неговите хостове, идентификационни данни и фрагменти. Искате ли да продължите?" }, "hostKey": { "verifyNewHost": "Проверете SSH хост ключа", @@ -2323,6 +2371,13 @@ "serviceLinks": "Връзки към услуги", "homepagePreview": "Преглед на началната страница" }, + "donation": { + "title": "Харесва ли ви Termix?", + "body": "Termix е безплатен и с отворен код, разработен и поддържан от екип от двама души в свободното ни време. Ако замести търговски инструмент, за който иначе бихте платили, дарението помага за покриване на разходите за хостинг и поддържа разработката. Към момента даренията са само в криптовалута.", + "milestones": "Даренията също помагат за финансиране на времето за проучване и изучаване на необходимото за изграждане на функции като поддръжка на SAML и Kubernetes. Вижте напредъка на страницата за дарения.", + "cta": "Дарете", + "dismiss": "Може би по-късно" + }, "dashboardTab": { "stable": "СТАБИЛЕН", "hostsOnline": "Домакините онлайн", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Актуализирането на администраторския статус не бе успешно", "allSessionsRevoked": "Всички сесии са отменени", "revokeSessionsFailed": "Неуспешно отменяне на сесиите", + "manageUserData": "Управление на потребителските данни", + "backToUsers": "Обратно към потребителите", + "manageTabAccount": "Профил", + "manageTabHosts": "Домакини", + "manageTabCredentials": "Пълномощия", + "manageTabSnippets": "Откъси", + "manageTabSessions": "Сесии", + "manageTabDanger": "Опасност", + "manageEditorBack": "Обратно към данните на {{username}}", + "dataLockedBadge": "ЗАКЛЮЧЕНО", + "dataLockedNotice": "Данните на {{username}}остават заключени до следващото им влизане. Техните хостове, идентификационни данни и фрагменти не могат да бъдат преглеждани или редактирани дотогава.", + "resetPasswordTitle": "Нулиране на парола", + "resetPasswordOidcOnly": "Този потребител влиза чрез външен доставчик и няма парола.", + "resetPasswordPlaceholder": "Нова парола", + "resetPasswordBtn": "Нулиране", + "resetPasswordWorking": "Нулиране...", + "resetPasswordSuccess": "Възстановяване на паролата", + "resetPasswordSuccessWiped": "Нулиране на паролата. Криптираните данни на потребителя бяха изтрити.", + "resetPasswordFailed": "Неуспешно нулиране на паролата", + "resetPasswordConfirmWipe": "{{username}} не е влизал в системата след надстройката на криптирането, така че данните му не могат да бъдат възстановени. Нулирането сега ще изтрие неговите хостове, идентификационни данни и фрагменти. Искате ли да продължите?", + "totpSectionTitle": "Двуфакторно удостоверяване", + "totpStatusEnabled": "TOTP е активиран за този потребител", + "totpStatusDisabled": "TOTP не е активиран за този потребител", + "disableTotp": "Деактивиране", + "disableTotpConfirm": "Деактивиране на двуфакторното удостоверяване за {{username}}? Те ще могат да влизат само с паролата си.", + "totpDisabledSuccess": "TOTP е деактивиран", + "totpDisableFailed": "Деактивирането на TOTP не бе успешно", + "manageApiKeys": "API ключове", + "apiKeyNamePlaceholder": "Име на ключа", + "apiKeyCopyNotice": "Копирайте този ключ сега, той няма да се показва отново.", + "noApiKeysForUser": "Няма API ключове", + "apiKeyDeleteFailed": "Изтриването на API ключа не бе успешно", + "exportUserData": "Експортиране на данни", + "exportUserDataDesc": "Изтеглете хостовете, идентификационните данни и данните за файловия мениджър на този потребител като JSON. Тайните данни са дешифрирани.", + "exportUserDataSuccess": "Експортирани потребителски данни", + "exportUserDataFailed": "Експортирането на потребителските данни не бе успешно", + "hostsCount": "{{count}} хостове", + "addHostForUser": "Добавяне на хост", + "noHostsForUser": "Този потребител няма хостове", + "connectToHost": "Свържете се", + "deleteHostConfirm": "Да се изтрие ли хостът „{{name}}“, принадлежащ на {{username}}?", + "hostDeletedSuccess": "Хостът е изтрит", + "hostDeleteFailed": "Изтриването на хоста не бе успешно", + "credentialsCount": "{{count}} идентификационни данни", + "addCredentialForUser": "Добавяне на идентификационни данни", + "noCredentialsForUser": "Този потребител няма идентификационни данни", + "deleteCredentialConfirm": "Да се изтрият ли идентификационните данни „{{name}}“, принадлежащи на {{username}}?", + "credentialDeletedSuccess": "Идентификационните данни са изтрити", + "credentialDeleteFailed": "Неуспешно изтриване на идентификационните данни", + "snippetsCount": "{{count}} фрагменти", + "addSnippetForUser": "Добавяне на фрагмент", + "noSnippetsForUser": "Този потребител няма фрагменти", + "snippetRequiredFields": "Името и съдържанието на фрагмента са задължителни", + "snippetNamePlaceholder": "Име на фрагмента", + "snippetContentPlaceholder": "Съдържание на командата", + "snippetFolderPlaceholder": "Папка (по избор)", + "snippetSaved": "Фрагментът е запазен", + "snippetSaveFailed": "Запазването на фрагмента не бе успешно", + "deleteSnippetConfirm": "Да се изтрие ли фрагментът „{{name}}“, принадлежащ на {{username}}?", + "snippetDeletedSuccess": "Фрагментът е изтрит", + "snippetDeleteFailed": "Изтриването на фрагмента не бе успешно", + "noSessionsForUser": "Няма активни сесии", + "deleteUserDangerDesc": "Изтриване за постоянно на {{username}} и всички негови данни (хостове, идентификационни данни, фрагменти, история). Това не може да бъде отменено.", + "deleteUserConfirm": "Да се изтрие ли окончателно {{username}} и всичките му данни?", + "deleteUserAdminBlocked": "Премахнете администраторския статус, преди да изтриете този потребител.", "createRoleRequired": "Името и показваното име са задължителни", "createRoleSuccess": "Ролята „{{name}}“ е създадена", "createRoleFailed": "Създаването на роля не бе успешно", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "История на командите", "hostDefaultsCommandHistoryDesc": "Проследяване на историята на командите на нови хостове по подразбиране", "hostDefaultsSaved": "Настройките по подразбиране на хоста са запазени", - "hostDefaultsSaveFailed": "Запазването на настройките по подразбиране на хоста не бе успешно" + "hostDefaultsSaveFailed": "Запазването на настройките по подразбиране на хоста не бе успешно", + "rolePermissions": { + "count": "{{count}} permissions", + "editAction": "Редактиране на разрешенията", + "loadError": "Зареждането на каталога с разрешения не бе успешно", + "saved": "Разрешенията за роли са запазени", + "saveError": "Запазването на разрешенията за ролята не бе успешно", + "save": "Запазване", + "saving": "Запазване..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Неуспешно" }, "userProfile": { + "donateTitle": "Пази Термикс жив", + "donateDescription": "Termix е безплатен, самостоятелно хостван и изграден само от няколко души в свободното им време. Ако ви е спестил време или пари, дарението от крипто помага да продължи да функционира.", + "donateMilestones": "Даренията помагат да се финансира времето за проучване и изучаване на необходимото за изграждане на поддръжка за SAML, Kubernetes и Agent. Вижте напредъка и дарете.", + "donateButton": "Дарете криптовалута", "storageModeLocal": "Браузър", "storageModeCloud": "База данни", "storageModeDescription": "Браузърът съхранява настройките само в този браузър. Базата данни се синхронизира със сървъра и се зарежда на всяко устройство.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Включено", "twoFaOff": "Изключено", "versionLabel": "Версия", + "betaProgramTitle": "Бета програма", + "betaProgramDescription": "Изпробвайте предстоящите функции рано със седмичния подвижен таг :beta на Docker. Нестабилна версия, не е за производство.", + "betaProgramFeedback": "Открихте грешка? Докладвайте я тук.", + "betaProgramLearnMore": "Научете повече", "deleteAccount": "Изтриване на акаунт", "deleteAccountDescription": "Изтрийте профила си завинаги", "changeServerDescription": "Преминете към друг Termix backend сървър", diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index 12a8893d..b28b62b8 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "কমা দ্বারা পৃথক করা নামের প্যাটার্ন যা ডকারকে গেস্টদের মেলানোর জন্য সক্ষম করে", "proxmoxPreferredRanges": "পছন্দের আইপি রেঞ্জ", "proxmoxPreferredRangesDesc": "যখন কোনো গেস্টের একাধিক ইন্টারফেস থাকে, তখন আইপি নির্বাচনের জন্য অগ্রাধিকার ক্রমে কমা দ্বারা পৃথক করা প্রিফিক্সগুলো ব্যবহৃত হয়।", + "proxmoxAutoSync": "অতিথিদের স্বয়ংক্রিয়ভাবে সিঙ্ক করুন", + "proxmoxAutoSyncDesc": "আপনার সেশন আনলক থাকা অবস্থায় পর্যায়ক্রমে এই প্রক্সমক্স নোডটি আবিষ্কার করুন এবং ইম্পোর্ট করা গেস্ট হোস্টগুলি তৈরি বা আপডেট করুন।", + "proxmoxSyncInterval": "সিঙ্ক ব্যবধান (মিনিট)", + "proxmoxSyncIntervalDesc": "সর্বনিম্ন ৫ মিনিট। শিডিউলারটি লক করা ব্যবহারকারীর সেশনগুলো এড়িয়ে যায়।", + "proxmoxMarkMissing": "অনুপস্থিত অতিথিদের চিহ্নিত করুন", + "proxmoxMarkMissingDesc": "পূর্বে ইম্পোর্ট করা কোনো গেস্ট অদৃশ্য হয়ে গেলে, সেটিকে ডিলিট না করে একটি proxmox-missing ট্যাগ যোগ করুন।", + "proxmoxLastSync": "শেষ সিঙ্ক", + "proxmoxLastSyncNever": "এখনো সিঙ্ক হয়নি", + "proxmoxLastSyncNoResult": "ফলাফলের কোনো বিবরণ নেই", + "proxmoxLastSyncSummary": "{{created}} তৈরি করা হয়েছে, {{updated}} আপডেট করা হয়েছে, {{markedMissing}} অনুপস্থিত, {{skipped}} বাদ দেওয়া হয়েছে", + "proxmoxLastSyncStatus": { + "success": "সাফল্য", + "error": "ব্যর্থ", + "pending": "বিচারাধীন" + }, "proxmoxDiscoverAction": "Proxmox গেস্টদের আবিষ্কার ও আমদানি করুন", "proxmoxImportTitle": "প্রক্সমক্স থেকে আমদানি করুন", "proxmoxSelectHost": "একটি প্রক্সমক্স হোস্ট নির্বাচন করুন…", @@ -1167,7 +1182,57 @@ "shareHost": "হোস্ট শেয়ার করুন", "shareHostTitle": "শেয়ার করুন: {{name}}", "sharing": { - "requiresCredential": "শেয়ারিং সক্ষম করতে এই হোস্টকে অবশ্যই একটি ক্রেডেনশিয়াল ব্যবহার করতে হবে। প্রথমে হোস্টটি সম্পাদনা করুন এবং একটি ক্রেডেনশিয়াল নির্ধারণ করুন।" + "loadError": "শেয়ারিং ডেটা লোড করা সম্ভব হয়নি। অনুগ্রহ করে আবার চেষ্টা করুন।", + "shareWithSection": "শেয়ার করুন", + "usersTab": "ব্যবহারকারীরা", + "rolesTab": "ভূমিকা", + "searchPlaceholder": "ব্যবহারকারী বা ভূমিকা অনুসন্ধান করুন...", + "noMatches": "কোনো মিল খুঁজে পাওয়া যায়নি", + "permissionLevelLabel": "অনুমতি স্তর", + "levels": { + "connect": { + "label": "সংযোগ করুন", + "description": "শুধুমাত্র টার্মিনাল, রিমোট ডেস্কটপ, ফাইল ম্যানেজার, টানেল এবং ডকার সেশনগুলো খোলা থাকবে। হোস্ট কনফিগারেশনে কোনো অ্যাক্সেস নেই।" + }, + "view": { + "label": "দেখুন", + "description": "সংযোগ করুন, এবং হোস্ট কনফিগারেশন দেখুন। গোপনীয় তথ্য কখনোই দেখানো হয় না।" + }, + "edit": { + "label": "সম্পাদনা", + "description": "হোস্টটি দেখুন এবং পরিবর্তন করুন। গোপনীয় তথ্য প্রতিস্থাপন করা গেলেও পড়া যাবে না; ক্রেডেনশিয়াল অ্যাসাইনমেন্ট শুধুমাত্র মালিকের কাছেই থাকবে।" + }, + "manage": { + "label": "পরিচালনা করুন", + "description": "সম্পাদনা করুন, সেইসাথে অন্যদের সাথে হোস্টটি শেয়ার করুন, অনুমতির স্তর পরিবর্তন করুন এবং অ্যাক্সেস প্রত্যাহার করুন।" + } + }, + "expiryLabel": "অ্যাক্সেসের মেয়াদ শেষ", + "expiry": { + "never": "কখনো না", + "oneHour": "১ ঘন্টা", + "oneDay": "২৪ ঘন্টা", + "sevenDays": "৭ দিন", + "thirtyDays": "৩০ দিন", + "custom": "কাস্টম" + }, + "customHoursPlaceholder": "অ্যাক্সেসের মেয়াদ শেষ হতে বাকি ঘন্টা", + "shareButton": "শেয়ার", + "shareWithCount": "শেয়ার করুন ({{count}})", + "currentAccess": "বর্তমান অ্যাক্সেস", + "noAccessEntries": "এই হোস্টটি এখনও শেয়ার করা হয়নি।", + "grantedBy": "মঞ্জুর করেছেন", + "expires": "মেয়াদ শেষ", + "expired": "মেয়াদোত্তীর্ণ", + "never": "কখনো না", + "revoke": "বাতিল করুন", + "accessUpdated": "অ্যাক্সেস আপডেট করা হয়েছে", + "accessUpdateFailed": "অ্যাক্সেস আপডেট করতে ব্যর্থ হয়েছে", + "sharedBadge": "শেয়ার করা", + "sharedBadgeTooltip": "{{owner}} ({{level}} অ্যাক্সেস) দ্বারা শেয়ার করা হয়েছে", + "viewOnlyBanner": "এই হোস্টটি {{owner}} আপনার সাথে দেখার অ্যাক্সেস সহ শেয়ার করেছে। কনফিগারেশনটি শুধুমাত্র পঠনযোগ্য।", + "sharedEditBanner": "এই হোস্টটি {{owner}} দ্বারা আপনার সাথে সম্পাদনার অ্যাক্সেস সহ শেয়ার করা হয়েছে। পরিবর্তনগুলি আসল হোস্টে প্রযোজ্য হবে; প্রমাণীকরণ রেফারেন্স শুধুমাত্র মালিক দ্বারা পরিবর্তন করা যাবে।", + "ownerOnlyControl": "শুধুমাত্র হোস্টের মালিকই এটি পরিবর্তন করতে পারেন।" }, "guac": { "connection": "সংযোগ", @@ -1302,28 +1367,10 @@ "backspaceKey": "ব্যাকস্পেস কী", "saveHostFirst": "প্রথমে হোস্টটি সংরক্ষণ করুন।", "sharingOptionsAfterSave": "হোস্ট সংরক্ষণ করার পর শেয়ার করার বিকল্পগুলো পাওয়া যাবে।", - "sharingLoadError": "শেয়ারিং ডেটা লোড করা সম্ভব হয়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন।", - "shareHostSection": "হোস্ট শেয়ার করুন", - "shareWithUser": "ব্যবহারকারীর সাথে শেয়ার করুন", - "shareWithRole": "ভূমিকার সাথে শেয়ার করুন", - "selectUser": "ব্যবহারকারী নির্বাচন করুন", - "selectRole": "ভূমিকা নির্বাচন করুন", - "selectUserOption": "একজন ব্যবহারকারী নির্বাচন করুন...", - "selectRoleOption": "একটি ভূমিকা নির্বাচন করুন...", "permissionLevel": "অনুমতি স্তর", - "expiresInHours": "(ঘন্টায়) মেয়াদ শেষ হবে", - "noExpiryPlaceholder": "মেয়াদ শেষ না হওয়ার জন্য খালি রাখুন।", - "shareBtn": "শেয়ার", - "currentAccess": "বর্তমান অ্যাক্সেস", "typeHeader": "প্রকার", "targetHeader": "লক্ষ্য", "permissionHeader": "অনুমতি", - "grantedByHeader": "মঞ্জুর করেছেন", - "expiresHeader": "মেয়াদ শেষ", - "noAccessEntries": "এখনো কোনো প্রবেশাধিকার দেওয়া হয়নি।", - "expiredLabel": "মেয়াদোত্তীর্ণ", - "neverLabel": "কখনো না", - "revokeBtn": "বাতিল করুন", "cancelBtn": "বাতিল করুন", "savingBtn": "সঞ্চয়...", "updateHostBtn": "হোস্ট আপডেট করুন", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "পাসওয়ার্ড দিয়ে লগইন করা বন্ধ আছে। একটি পাসকি অথবা বাহ্যিক প্রমাণীকরণ প্রদানকারী ব্যবহার করুন।", "signInWithPasskey": "পাসকি দিয়ে সাইন ইন করুন", "passkeyLoginFailed": "পাসকি লগইন ব্যর্থ হয়েছে", - "attemptsRemaining": "{{count}} টি প্রচেষ্টা বাকি" + "attemptsRemaining": "{{count}} টি প্রচেষ্টা বাকি", + "confirmResetDataWipe": "এনক্রিপশন আপগ্রেডের পর থেকে এই অ্যাকাউন্টটি লগ ইন করা হয়নি, তাই পুরোনো পাসওয়ার্ড ছাড়া এর সংরক্ষিত ডেটা পুনরুদ্ধার করা যাবে না। রিসেট করলে এর হোস্ট, ক্রেডেনশিয়াল এবং স্নিপেট স্থায়ীভাবে মুছে যাবে। চালিয়ে যাবেন?" }, "hostKey": { "verifyNewHost": "SSH হোস্ট কী যাচাই করুন", @@ -2323,6 +2371,13 @@ "serviceLinks": "পরিষেবা লিঙ্ক", "homepagePreview": "হোমপেজ প্রিভিউ" }, + "donation": { + "title": "টারমিক্স উপভোগ করছেন?", + "body": "টারমিক্স একটি ফ্রি এবং ওপেন সোর্স টুল, যা আমাদের দুইজনের একটি দল অবসর সময়ে তৈরি ও রক্ষণাবেক্ষণ করে। যদি এটি আপনার কেনা কোনো বাণিজ্যিক টুলের বিকল্প হয়ে ওঠে, তবে আপনার অনুদান হোস্টিং খরচ মেটাতে এবং এর উন্নয়ন চালিয়ে যেতে সাহায্য করে। আপাতত, অনুদান শুধুমাত্র ক্রিপ্টোকারেন্সির মাধ্যমে গ্রহণ করা হয়।", + "milestones": "অনুদান SAML এবং Kubernetes সাপোর্টের মতো ফিচারগুলো তৈরি করার জন্য প্রয়োজনীয় গবেষণা ও শেখার সময় জোগাতেও সাহায্য করে। অনুদান পেজে এর অগ্রগতি দেখুন।", + "cta": "দান করুন", + "dismiss": "হয়তো পরে" + }, "dashboardTab": { "stable": "স্থিতিশীল", "hostsOnline": "হোস্ট অনলাইন", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "অ্যাডমিন স্ট্যাটাস আপডেট করতে ব্যর্থ হয়েছে", "allSessionsRevoked": "সকল অধিবেশন বাতিল করা হয়েছে", "revokeSessionsFailed": "সেশন বাতিল করতে ব্যর্থ হয়েছে", + "manageUserData": "ব্যবহারকারীর ডেটা পরিচালনা করুন", + "backToUsers": "ব্যবহারকারীদের কাছে ফিরে যান", + "manageTabAccount": "অ্যাকাউন্ট", + "manageTabHosts": "হোস্টরা", + "manageTabCredentials": "যোগ্যতা", + "manageTabSnippets": "খণ্ডাংশ", + "manageTabSessions": "অধিবেশন", + "manageTabDanger": "বিপদ", + "manageEditorBack": "{{username}}এর ডেটাতে ফিরে যান", + "dataLockedBadge": "লক করা", + "dataLockedNotice": "{{username}}-এর ডেটা পরবর্তী লগইন পর্যন্ত লক করা থাকে। ততক্ষণ পর্যন্ত তাদের হোস্ট, ক্রেডেনশিয়াল এবং স্নিপেট দেখা বা সম্পাদনা করা যায় না।", + "resetPasswordTitle": "পাসওয়ার্ড রিসেট করুন", + "resetPasswordOidcOnly": "এই ব্যবহারকারী একটি বাহ্যিক প্রদানকারীর মাধ্যমে সাইন ইন করেন এবং তার কোনো পাসওয়ার্ড নেই।", + "resetPasswordPlaceholder": "নতুন পাসওয়ার্ড", + "resetPasswordBtn": "রিসেট", + "resetPasswordWorking": "রিসেট করা হচ্ছে...", + "resetPasswordSuccess": "পাসওয়ার্ড রিসেট", + "resetPasswordSuccessWiped": "পাসওয়ার্ড রিসেট করা হয়েছে। ব্যবহারকারীর এনক্রিপ্টেড ডেটা মুছে ফেলা হয়েছে।", + "resetPasswordFailed": "পাসওয়ার্ড রিসেট করতে ব্যর্থ হয়েছে", + "resetPasswordConfirmWipe": "এনক্রিপশন আপগ্রেডের পর থেকে {{username}} লগ ইন করেননি, তাই তাদের ডেটা পুনরুদ্ধার করা সম্ভব নয়। এখন রিসেট করলে তাদের হোস্ট, ক্রেডেনশিয়াল এবং স্নিপেট মুছে যাবে। চালিয়ে যাবেন?", + "totpSectionTitle": "দ্বি-স্তরীয় প্রমাণীকরণ", + "totpStatusEnabled": "এই ব্যবহারকারীর জন্য TOTP সক্রিয় করা হয়েছে।", + "totpStatusDisabled": "এই ব্যবহারকারীর জন্য TOTP সক্রিয় করা নেই।", + "disableTotp": "অক্ষম করুন", + "disableTotpConfirm": "{{username}}এর জন্য দ্বি-স্তরীয় প্রমাণীকরণ নিষ্ক্রিয় করবেন? তারা শুধুমাত্র তাদের পাসওয়ার্ড দিয়ে লগ ইন করতে পারবে।", + "totpDisabledSuccess": "TOTP নিষ্ক্রিয়", + "totpDisableFailed": "TOTP নিষ্ক্রিয় করতে ব্যর্থ হয়েছে", + "manageApiKeys": "এপিআই কী", + "apiKeyNamePlaceholder": "মূল নাম", + "apiKeyCopyNotice": "এই কী-টি এখন কপি করুন, এটি আর দেখানো হবে না।", + "noApiKeysForUser": "কোন এপিআই কী নেই", + "apiKeyDeleteFailed": "এপিআই কী মুছে ফেলতে ব্যর্থ হয়েছে", + "exportUserData": "ডেটা রপ্তানি", + "exportUserDataDesc": "এই ব্যবহারকারীর হোস্ট, ক্রেডেনশিয়াল এবং ফাইল ম্যানেজার ডেটা JSON ফরম্যাটে ডাউনলোড করুন। গোপনীয় তথ্য ডিক্রিপ্ট করা আছে।", + "exportUserDataSuccess": "ব্যবহারকারীর ডেটা রপ্তানি করা হয়েছে", + "exportUserDataFailed": "ব্যবহারকারীর ডেটা রপ্তানি করতে ব্যর্থ হয়েছে", + "hostsCount": "{{count}} হোস্ট", + "addHostForUser": "হোস্ট যোগ করুন", + "noHostsForUser": "এই ব্যবহারকারীর কোনো হোস্ট নেই", + "connectToHost": "সংযোগ করুন", + "deleteHostConfirm": "{{username}} এর অন্তর্গত হোস্ট \"{{name}}\" মুছে ফেলুন?", + "hostDeletedSuccess": "হোস্ট মুছে ফেলা হয়েছে", + "hostDeleteFailed": "হোস্ট মুছে ফেলতে ব্যর্থ হয়েছে", + "credentialsCount": "{{count}} পরিচয়পত্র", + "addCredentialForUser": "পরিচয়পত্র যোগ করুন", + "noCredentialsForUser": "এই ব্যবহারকারীর কোনো পরিচয়পত্র নেই।", + "deleteCredentialConfirm": "{{username}} এর অন্তর্গত পরিচয়পত্র \"{{name}}\" মুছে ফেলবেন?", + "credentialDeletedSuccess": "পরিচয়পত্র মুছে ফেলা হয়েছে", + "credentialDeleteFailed": "ক্রেডেনশিয়াল মুছে ফেলতে ব্যর্থ হয়েছে", + "snippetsCount": "{{count}} খণ্ডাংশ", + "addSnippetForUser": "স্নিপেট যোগ করুন", + "noSnippetsForUser": "এই ব্যবহারকারীর কোনো স্নিপেট নেই", + "snippetRequiredFields": "স্নিপেটের নাম এবং বিষয়বস্তু আবশ্যক।", + "snippetNamePlaceholder": "স্নিপেট নাম", + "snippetContentPlaceholder": "কমান্ডের বিষয়বস্তু", + "snippetFolderPlaceholder": "ফোল্ডার (ঐচ্ছিক)", + "snippetSaved": "স্নিপেট সংরক্ষিত", + "snippetSaveFailed": "স্নিপেট সংরক্ষণ করতে ব্যর্থ হয়েছে", + "deleteSnippetConfirm": "{{username}} এর অন্তর্গত স্নিপেট \"{{name}}\" মুছে ফেলবেন?", + "snippetDeletedSuccess": "স্নিপেট মুছে ফেলা হয়েছে", + "snippetDeleteFailed": "স্নিপেট মুছে ফেলা ব্যর্থ হয়েছে", + "noSessionsForUser": "কোন সক্রিয় সেশন নেই", + "deleteUserDangerDesc": "{{username}} এবং তাদের সমস্ত ডেটা (হোস্ট, ক্রেডেনশিয়াল, স্নিপেট, হিস্ট্রি) স্থায়ীভাবে মুছে ফেলা হলো। এটি আর পূর্বাবস্থায় ফেরানো যাবে না।", + "deleteUserConfirm": "{{username}} এবং তাদের সমস্ত ডেটা স্থায়ীভাবে মুছে ফেলা হবে?", + "deleteUserAdminBlocked": "এই ব্যবহারকারীকে মুছে ফেলার আগে অ্যাডমিন স্ট্যাটাস অপসারণ করুন।", "createRoleRequired": "নাম এবং প্রদর্শিত নাম আবশ্যক", "createRoleSuccess": "ভূমিকা \"{{name}}\" তৈরি করা হয়েছে", "createRoleFailed": "ভূমিকা তৈরি করতে ব্যর্থ হয়েছে", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "কমান্ডের ইতিহাস", "hostDefaultsCommandHistoryDesc": "ডিফল্টরূপে নতুন হোস্টগুলিতে কমান্ড হিস্ট্রি ট্র্যাক করুন।", "hostDefaultsSaved": "হোস্ট ডিফল্ট সংরক্ষিত", - "hostDefaultsSaveFailed": "হোস্ট ডিফল্ট সংরক্ষণ করতে ব্যর্থ হয়েছে" + "hostDefaultsSaveFailed": "হোস্ট ডিফল্ট সংরক্ষণ করতে ব্যর্থ হয়েছে", + "rolePermissions": { + "count": "{{count}} অনুমতি", + "editAction": "সম্পাদনার অনুমতি", + "loadError": "অনুমতি ক্যাটালগ লোড করতে ব্যর্থ হয়েছে", + "saved": "ভূমিকার অনুমতি সংরক্ষিত হয়েছে", + "saveError": "ভূমিকার অনুমতি সংরক্ষণ করতে ব্যর্থ হয়েছে", + "save": "সংরক্ষণ করুন", + "saving": "সঞ্চয়..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "ব্যর্থ" }, "userProfile": { + "donateTitle": "টারমিক্সকে বাঁচিয়ে রাখুন", + "donateDescription": "টারমিক্স বিনামূল্যে, সেলফ-হোস্টেড এবং মাত্র কয়েকজন ব্যক্তি তাদের অবসর সময়ে এটি তৈরি করেছেন। যদি এটি আপনার সময় বা অর্থ বাঁচিয়ে থাকে, তবে একটি ক্রিপ্টো অনুদান এটিকে চালিয়ে যেতে সাহায্য করে।", + "donateMilestones": "SAML, Kubernetes, এবং Agent সাপোর্ট তৈরির জন্য প্রয়োজনীয় গবেষণা ও শেখার কাজে অর্থায়নে অনুদান সাহায্য করে। অগ্রগতি দেখুন এবং অনুদান দিন।", + "donateButton": "ক্রিপ্টো দান করুন", "storageModeLocal": "ব্রাউজার", "storageModeCloud": "ডাটাবেস", "storageModeDescription": "ব্রাউজার শুধুমাত্র এই ব্রাউজারেই সেটিংস সংরক্ষণ করে। ডাটাবেস সার্ভারের সাথে সিঙ্ক হয় এবং যেকোনো ডিভাইসে লোড হয়।", @@ -3011,6 +3144,10 @@ "twoFaOn": "চালু", "twoFaOff": "বন্ধ", "versionLabel": "সংস্করণ", + "betaProgramTitle": "বিটা প্রোগ্রাম", + "betaProgramDescription": "সাপ্তাহিক রোলিং :beta ডকার ট্যাগ ব্যবহার করে আসন্ন ফিচারগুলো আগেভাগেই ব্যবহার করে দেখুন। এটি অস্থিতিশীল, প্রোডাকশনের জন্য নয়।", + "betaProgramFeedback": "কোনো ত্রুটি খুঁজে পেয়েছেন? এখানে জানান।", + "betaProgramLearnMore": "আরও জানুন", "deleteAccount": "অ্যাকাউন্ট মুছে ফেলুন", "deleteAccountDescription": "আপনার অ্যাকাউন্ট স্থায়ীভাবে মুছে ফেলুন", "changeServerDescription": "অন্য একটি টারমিক্স ব্যাকএন্ড সার্ভারে স্যুইচ করুন", diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index 25d6e25d..a3990f3f 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Patrons de noms separats per comes que permeten a Docker utilitzar convidats coincidents", "proxmoxPreferredRanges": "Intervals d'IP preferits", "proxmoxPreferredRangesDesc": "Prefixos separats per comes en ordre de prioritat per a la selecció d'IP quan un convidat té diverses interfícies", + "proxmoxAutoSync": "Sincronitza automàticament els convidats", + "proxmoxAutoSyncDesc": "Descobreix periòdicament aquest node de Proxmox i crea o actualitza els hosts convidats importats mentre la sessió està desbloquejada.", + "proxmoxSyncInterval": "Interval de sincronització (minuts)", + "proxmoxSyncIntervalDesc": "Mínim 5 minuts. El planificador omet les sessions d'usuari bloquejades.", + "proxmoxMarkMissing": "Marca els convidats que falten", + "proxmoxMarkMissingDesc": "Afegeix una etiqueta proxmox-missing quan un convidat importat prèviament desapareix en comptes d'eliminar-lo.", + "proxmoxLastSync": "Darrera sincronització", + "proxmoxLastSyncNever": "Encara no sincronitzat", + "proxmoxLastSyncNoResult": "Sense detalls del resultat", + "proxmoxLastSyncSummary": "{{created}} creat, {{updated}} actualitzat, {{markedMissing}} no hi és, {{skipped}} omès", + "proxmoxLastSyncStatus": { + "success": "Èxit", + "error": "Fallat", + "pending": "Pendent" + }, "proxmoxDiscoverAction": "Descobreix i importa convidats de Proxmox", "proxmoxImportTitle": "Importar des de Proxmox", "proxmoxSelectHost": "Seleccioneu un amfitrió de Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Comparteix l'amfitrió", "shareHostTitle": "Comparteix: {{name}}", "sharing": { - "requiresCredential": "Aquest amfitrió ha d'utilitzar una credencial per habilitar la compartició. Editeu l'amfitrió i assigneu-li primer una credencial." + "loadError": "No s'han pogut carregar les dades de compartició. Torna-ho a provar.", + "shareWithSection": "Comparteix amb", + "usersTab": "Usuaris", + "rolesTab": "Rols", + "searchPlaceholder": "Cerca usuaris o rols...", + "noMatches": "No s'han trobat coincidències", + "permissionLevelLabel": "Nivell de permís", + "levels": { + "connect": { + "label": "Connecta", + "description": "Només sessions obertes: terminal, escriptori remot, gestor de fitxers, túnels i Docker. No hi ha accés a la configuració de l'amfitrió." + }, + "view": { + "label": "Vista", + "description": "Connecta't i consulta la configuració de l'amfitrió. Els secrets no es mostren mai." + }, + "edit": { + "label": "Edita", + "description": "Visualitza i modifica l'amfitrió. Els secrets es poden substituir però mai es poden llegir; les assignacions de credencials només són del propietari." + }, + "manage": { + "label": "Gestiona", + "description": "Edita i comparteix l'amfitrió amb altres persones, canvia els nivells de permisos i revoca l'accés." + } + }, + "expiryLabel": "Caducitat de l'accés", + "expiry": { + "never": "Mai", + "oneHour": "1 hora", + "oneDay": "24 hores", + "sevenDays": "7 dies", + "thirtyDays": "30 dies", + "custom": "Personalitzat" + }, + "customHoursPlaceholder": "Hores fins que caduca l'accés", + "shareButton": "Comparteix", + "shareWithCount": "Comparteix ({{count}})", + "currentAccess": "Accés actual", + "noAccessEntries": "Aquest amfitrió encara no s'ha compartit", + "grantedBy": "Atorgat per", + "expires": "Caduca", + "expired": "Caducat", + "never": "Mai", + "revoke": "Revocar", + "accessUpdated": "Accés actualitzat", + "accessUpdateFailed": "No s'ha pogut actualitzar l'accés", + "sharedBadge": "Compartit", + "sharedBadgeTooltip": "Compartit per {{owner}} ( accés{{level}})", + "viewOnlyBanner": "Aquest amfitrió està compartit amb tu per {{owner}} amb accés de visualització. La configuració és de només lectura.", + "sharedEditBanner": "Aquest amfitrió està compartit amb tu per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'amfitrió real; les referències d'autenticació només les pot canviar el propietari.", + "ownerOnlyControl": "Només el propietari de l'amfitrió pot canviar això" }, "guac": { "connection": "Connexió", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tecla Retrocés", "saveHostFirst": "Primer desa l'amfitrió.", "sharingOptionsAfterSave": "Les opcions de compartició estan disponibles després que s'hagi desat l'amfitrió.", - "sharingLoadError": "No s'han pogut carregar les dades de compartició. Comprova la connexió i torna-ho a provar.", - "shareHostSection": "Comparteix l'amfitrió", - "shareWithUser": "Comparteix amb l'usuari", - "shareWithRole": "Comparteix amb el rol", - "selectUser": "Selecciona l'usuari", - "selectRole": "Selecciona un rol", - "selectUserOption": "Selecciona un usuari...", - "selectRoleOption": "Selecciona un rol...", "permissionLevel": "Nivell de permís", - "expiresInHours": "Caduca en (hores)", - "noExpiryPlaceholder": "Deixeu-ho en blanc si no hi ha caducitat", - "shareBtn": "Comparteix", - "currentAccess": "Accés actual", "typeHeader": "Tipus", "targetHeader": "Objectiu", "permissionHeader": "Permís", - "grantedByHeader": "Atorgat per", - "expiresHeader": "Caduca", - "noAccessEntries": "Encara no hi ha entrades d'accés.", - "expiredLabel": "Caducat", - "neverLabel": "Mai", - "revokeBtn": "Revocar", "cancelBtn": "Cancel·la", "savingBtn": "Desant...", "updateHostBtn": "Actualitza l'amfitrió", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "L'inici de sessió amb contrasenya està desactivat. Feu servir una contrasenya o un proveïdor d'autenticació extern.", "signInWithPasskey": "Inicia la sessió amb la clau d'accés", "passkeyLoginFailed": "Error d'inici de sessió amb contrasenya", - "attemptsRemaining": "{{count}} intents restants" + "attemptsRemaining": "{{count}} intents restants", + "confirmResetDataWipe": "Aquest compte no ha iniciat sessió des de l'actualització del xifratge, de manera que les dades emmagatzemades no es poden recuperar sense la contrasenya antiga. Si el restableixes, se suprimiran permanentment els seus amfitrions, credencials i fragments. Vols continuar?" }, "hostKey": { "verifyNewHost": "Verifica la clau d'amfitrió SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Enllaços de servei", "homepagePreview": "Vista prèvia de la pàgina d'inici" }, + "donation": { + "title": "T'agrada Termix?", + "body": "Termix és gratuït i de codi obert, construït i mantingut per un equip de dues persones en el nostre temps lliure. Si substitueix una eina comercial per la qual altrament estaríeu pagant, una donació ajuda a cobrir els costos d'allotjament i manté el desenvolupament en marxa. De moment, les donacions són només de criptomoneda.", + "milestones": "Les donacions també ajuden a finançar el temps per investigar i aprendre què cal per crear funcions com ara la compatibilitat amb SAML i Kubernetes. Vegeu el progrés a la pàgina de donacions.", + "cta": "Donar", + "dismiss": "Potser més tard" + }, "dashboardTab": { "stable": "ESTABLE", "hostsOnline": "Amfitrions en línia", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "No s'ha pogut actualitzar l'estat de l'administrador", "allSessionsRevoked": "Totes les sessions revocades", "revokeSessionsFailed": "No s'han pogut revocar les sessions", + "manageUserData": "Gestiona les dades de l'usuari", + "backToUsers": "Torna als usuaris", + "manageTabAccount": "Compte", + "manageTabHosts": "Amfitrions", + "manageTabCredentials": "Credencials", + "manageTabSnippets": "Fragments", + "manageTabSessions": "Sessions", + "manageTabDanger": "Perill", + "manageEditorBack": "Torna a les dades de {{username}}", + "dataLockedBadge": "BLOQUEJAT", + "dataLockedNotice": "Les dades de {{username}}romanen bloquejades fins que inicien la sessió de nou. Els seus amfitrions, credencials i fragments no es poden veure ni editar fins aleshores.", + "resetPasswordTitle": "Restablir contrasenya", + "resetPasswordOidcOnly": "Aquest usuari inicia la sessió a través d'un proveïdor extern i no té contrasenya.", + "resetPasswordPlaceholder": "Nova contrasenya", + "resetPasswordBtn": "Restablir", + "resetPasswordWorking": "Restableix...", + "resetPasswordSuccess": "Restabliment de contrasenya", + "resetPasswordSuccessWiped": "Restabliment de la contrasenya. S'han esborrat les dades xifrades de l'usuari.", + "resetPasswordFailed": "No s'ha pogut restablir la contrasenya", + "resetPasswordConfirmWipe": "{{username}} no ha iniciat sessió des de l'actualització del xifratge, de manera que les seves dades no es poden recuperar. Si restableixes ara, se suprimiran els seus amfitrions, credencials i fragments. Vols continuar?", + "totpSectionTitle": "Autenticació de dos factors", + "totpStatusEnabled": "TOTP està habilitat per a aquest usuari", + "totpStatusDisabled": "El TOTP no està habilitat per a aquest usuari", + "disableTotp": "Desactiva", + "disableTotpConfirm": "Voleu desactivar l'autenticació de dos factors per a {{username}}? Només podran iniciar sessió amb la seva contrasenya.", + "totpDisabledSuccess": "TOTP desactivat", + "totpDisableFailed": "No s'ha pogut desactivar TOTP", + "manageApiKeys": "Claus API", + "apiKeyNamePlaceholder": "Nom de la clau", + "apiKeyCopyNotice": "Copia aquesta clau ara, no es tornarà a mostrar.", + "noApiKeysForUser": "Sense claus API", + "apiKeyDeleteFailed": "No s'ha pogut suprimir la clau de l'API", + "exportUserData": "Exportació de dades", + "exportUserDataDesc": "Baixa els hosts, les credencials i les dades del gestor de fitxers d'aquest usuari com a JSON. Els secrets es desencripten.", + "exportUserDataSuccess": "Dades d'usuari exportades", + "exportUserDataFailed": "No s'han pogut exportar les dades de l'usuari", + "hostsCount": "amfitrions {{count}}", + "addHostForUser": "Afegeix amfitrió", + "noHostsForUser": "Aquest usuari no té amfitrions", + "connectToHost": "Connecta", + "deleteHostConfirm": "Voleu suprimir l'amfitrió \"{{name}}\" que pertany a {{username}}?", + "hostDeletedSuccess": "L'amfitrió s'ha suprimit", + "hostDeleteFailed": "No s'ha pogut suprimir l'amfitrió", + "credentialsCount": "credencials {{count}}", + "addCredentialForUser": "Afegeix credencials", + "noCredentialsForUser": "Aquest usuari no té credencials", + "deleteCredentialConfirm": "Voleu suprimir la credencial \"{{name}}\" que pertany a {{username}}?", + "credentialDeletedSuccess": "Credencial suprimida", + "credentialDeleteFailed": "No s'ha pogut suprimir la credencial", + "snippetsCount": "{{count}} fragments", + "addSnippetForUser": "Afegeix un fragment", + "noSnippetsForUser": "Aquest usuari no té fragments", + "snippetRequiredFields": "El nom i el contingut del fragment són obligatoris", + "snippetNamePlaceholder": "Nom del fragment", + "snippetContentPlaceholder": "Contingut de l'ordre", + "snippetFolderPlaceholder": "Carpeta (opcional)", + "snippetSaved": "Fragment desat", + "snippetSaveFailed": "No s'ha pogut desar el fragment", + "deleteSnippetConfirm": "Voleu suprimir el fragment \"{{name}}\" pertanyent a {{username}}?", + "snippetDeletedSuccess": "Fragment suprimit", + "snippetDeleteFailed": "No s'ha pogut suprimir el fragment", + "noSessionsForUser": "No hi ha sessions actives", + "deleteUserDangerDesc": "Suprimeix permanentment {{username}} i totes les seves dades (amfitrions, credencials, fragments, historial). Aquesta acció no es pot desfer.", + "deleteUserConfirm": "Voleu suprimir permanentment {{username}} i totes les seves dades?", + "deleteUserAdminBlocked": "Treu l'estatus d'administrador abans de suprimir aquest usuari.", "createRoleRequired": "El nom i el nom de visualització són obligatoris", "createRoleSuccess": "S'ha creat el rol \"{{name}}\"", "createRoleFailed": "No s'ha pogut crear el rol", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Historial de comandaments", "hostDefaultsCommandHistoryDesc": "Fes un seguiment de l'historial d'ordres en els nous amfitrions per defecte", "hostDefaultsSaved": "Els valors predeterminats de l'amfitrió s'han desat", - "hostDefaultsSaveFailed": "No s'han pogut desar els valors predeterminats de l'amfitrió" + "hostDefaultsSaveFailed": "No s'han pogut desar els valors predeterminats de l'amfitrió", + "rolePermissions": { + "count": "permisos {{count}}", + "editAction": "Edita els permisos", + "loadError": "No s'ha pogut carregar el catàleg de permisos", + "saved": "Permisos de rol desats", + "saveError": "No s'han pogut desar els permisos de rol", + "save": "Desa", + "saving": "Desant..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Fallat" }, "userProfile": { + "donateTitle": "Mantingueu viva la Termix", + "donateDescription": "Termix és gratuït, s'allotja per si mateix i només l'han creat unes poques persones en el seu temps lliure. Si t'ha estalviat temps o diners, una donació de criptomonedes t'ajudarà a mantenir-lo en marxa.", + "donateMilestones": "Els donatius ajuden a finançar el temps per investigar i aprendre què cal per construir suport per a SAML, Kubernetes i Agents. Vegeu el progrés i feu una donació.", + "donateButton": "Dona criptomonedes", "storageModeLocal": "Navegador", "storageModeCloud": "Base de dades", "storageModeDescription": "El navegador només emmagatzema la configuració en aquest navegador. La base de dades es sincronitza amb el servidor i es carrega en qualsevol dispositiu.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Activat", "twoFaOff": "Desactivat", "versionLabel": "Versió", + "betaProgramTitle": "Programa Beta", + "betaProgramDescription": "Prova les properes funcions aviat amb l'etiqueta Docker setmanalment disponible :beta. Inestable, no per a producció.", + "betaProgramFeedback": "Has trobat un error? Informa'n aquí.", + "betaProgramLearnMore": "Més informació", "deleteAccount": "Suprimeix el compte", "deleteAccountDescription": "Elimina el teu compte permanentment", "changeServerDescription": "Canvia a un altre servidor de backend de Termix", diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index ffa39e49..27c8d253 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Vzory názvů oddělených čárkami, které Dockeru umožňují vyhledávání hostů", "proxmoxPreferredRanges": "Preferované rozsahy IP adres", "proxmoxPreferredRangesDesc": "Prefixy oddělené čárkami v pořadí priority pro výběr IP adresy, když má host více rozhraní", + "proxmoxAutoSync": "Automatická synchronizace hostů", + "proxmoxAutoSyncDesc": "Pravidelně vyhledávejte tento uzel Proxmox a vytvářejte nebo aktualizujte importované hostující hostitele, zatímco je vaše relace odemčená.", + "proxmoxSyncInterval": "Interval synchronizace (minuty)", + "proxmoxSyncIntervalDesc": "Minimálně 5 minut. Plánovač přeskakuje uzamčené uživatelské relace.", + "proxmoxMarkMissing": "Označte chybějící hosty", + "proxmoxMarkMissingDesc": "Přidat tag proxmox-missing, když dříve importovaný host zmizí, místo aby byl smazán.", + "proxmoxLastSync": "Poslední synchronizace", + "proxmoxLastSyncNever": "Zatím nesynchronizováno", + "proxmoxLastSyncNoResult": "Žádné podrobnosti o výsledcích", + "proxmoxLastSyncSummary": "{{created}} vytvořeno, {{updated}} aktualizováno, {{markedMissing}} chybí, {{skipped}} přeskočeno", + "proxmoxLastSyncStatus": { + "success": "Úspěch", + "error": "Selhalo", + "pending": "Čeká na vyřízení" + }, "proxmoxDiscoverAction": "Objevte a importujte hosty Proxmoxu", "proxmoxImportTitle": "Import z Proxmoxu", "proxmoxSelectHost": "Vyberte hostitele Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Sdílení hostitele", "shareHostTitle": "Sdílet: {{name}}", "sharing": { - "requiresCredential": "Tento hostitel musí pro sdílení používat přihlašovací údaje. Nejprve upravte hostitele a přiřaďte mu přihlašovací údaje." + "loadError": "Nepodařilo se načíst data sdílení. Zkuste to prosím znovu.", + "shareWithSection": "Sdílet s", + "usersTab": "Uživatelé", + "rolesTab": "Role", + "searchPlaceholder": "Hledat uživatele nebo role...", + "noMatches": "Nenalezeny žádné shody", + "permissionLevelLabel": "Úroveň oprávnění", + "levels": { + "connect": { + "label": "Připojit", + "description": "Pouze otevřené relace: terminál, vzdálená plocha, správce souborů, tunely a Docker. Bez přístupu ke konfiguraci hostitele." + }, + "view": { + "label": "Pohled", + "description": "Připojte se a podívejte se na konfiguraci hostitele. Tajné kódy se nikdy nezobrazují." + }, + "edit": { + "label": "Upravit", + "description": "Zobrazení a úprava hostitele. Tajné údaje lze nahradit, ale nikdy nečíst; přiřazení pověření zůstává pouze pro vlastníka." + }, + "manage": { + "label": "Spravovat", + "description": "Upravovat a sdílet hostitele s ostatními, měnit úrovně oprávnění a odebírat přístup." + } + }, + "expiryLabel": "Vypršení přístupu", + "expiry": { + "never": "Nikdy", + "oneHour": "1 hodina", + "oneDay": "24 hodin", + "sevenDays": "7 dní", + "thirtyDays": "30 dní", + "custom": "Zvyk" + }, + "customHoursPlaceholder": "Hodiny do vypršení přístupu", + "shareButton": "Podíl", + "shareWithCount": "Sdílet ({{count}})", + "currentAccess": "Aktuální přístup", + "noAccessEntries": "Tento hostitel ještě nebyl sdílen", + "grantedBy": "Uděleno", + "expires": "Platnost vyprší", + "expired": "Platnost vypršela", + "never": "Nikdy", + "revoke": "Zrušit", + "accessUpdated": "Přístup aktualizován", + "accessUpdateFailed": "Aktualizace přístupu se nezdařilo", + "sharedBadge": "Sdílené", + "sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} ( přístup{{level}})", + "viewOnlyBanner": "Tento hostitel je s vámi sdílen uživatelem {{owner}} s oprávněním pro zobrazení. Konfigurace je pouze pro čtení.", + "sharedEditBanner": "Tento hostitel je s vámi sdílen uživatelem {{owner}} s oprávněním k úpravám. Změny se vztahují na skutečného hostitele; ověřovací reference může změnit pouze vlastník.", + "ownerOnlyControl": "Toto může změnit pouze majitel hostitele" }, "guac": { "connection": "Spojení", @@ -1302,28 +1367,10 @@ "backspaceKey": "Klávesa Backspace", "saveHostFirst": "Nejprve uložte hostitele.", "sharingOptionsAfterSave": "Možnosti sdílení jsou k dispozici po uložení hostitele.", - "sharingLoadError": "Nepodařilo se načíst data sdílení. Zkontrolujte připojení a zkuste to znovu.", - "shareHostSection": "Sdílení hostitele", - "shareWithUser": "Sdílet s uživatelem", - "shareWithRole": "Sdílet s rolí", - "selectUser": "Vyberte uživatele", - "selectRole": "Vyberte roli", - "selectUserOption": "Vyberte uživatele...", - "selectRoleOption": "Vyberte roli...", "permissionLevel": "Úroveň oprávnění", - "expiresInHours": "Platnost vyprší za (hodiny)", - "noExpiryPlaceholder": "Pro neomezenou platnost ponechte prázdné.", - "shareBtn": "Podíl", - "currentAccess": "Aktuální přístup", "typeHeader": "Typ", "targetHeader": "Cíl", "permissionHeader": "Povolení", - "grantedByHeader": "Uděleno kým", - "expiresHeader": "Platnost vyprší", - "noAccessEntries": "Zatím žádné přístupové položky.", - "expiredLabel": "Platnost vypršela", - "neverLabel": "Nikdy", - "revokeBtn": "Zrušit", "cancelBtn": "Zrušit", "savingBtn": "Ukládání...", "updateHostBtn": "Aktualizovat hostitele", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Přihlášení heslem je zakázáno. Použijte přístupový klíč nebo externího poskytovatele ověřování.", "signInWithPasskey": "Přihlásit se pomocí hesla", "passkeyLoginFailed": "Přihlášení k heslu se nezdařilo", - "attemptsRemaining": "Zbývá {{count}} pokusů" + "attemptsRemaining": "Zbývá {{count}} pokusů", + "confirmResetDataWipe": "Tento účet se od aktualizace šifrování nepřihlásil, takže jeho uložená data nelze obnovit bez starého hesla. Resetováním se trvale smažou jeho hostitelé, přihlašovací údaje a úryvky. Pokračovat?" }, "hostKey": { "verifyNewHost": "Ověření klíče hostitele SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Odkazy na služby", "homepagePreview": "Náhled domovské stránky" }, + "donation": { + "title": "Líbí se vám Termix?", + "body": "Termix je bezplatný a open source nástroj, který vytváří a spravuje dvoučlenný tým ve svém volném čase. Pokud nahradí komerční nástroj, za který byste jinak platili, dar pomůže pokrýt náklady na hosting a udrží vývoj v chodu. V současné době jsou dary pouze v kryptoměnách.", + "milestones": "Dary také pomáhají financovat čas na výzkum a zjištění, co je potřeba k vytvoření funkcí, jako je podpora SAML a Kubernetes. Sledujte průběh na stránce pro darování.", + "cta": "Darovat", + "dismiss": "Možná později" + }, "dashboardTab": { "stable": "STABILNÍ", "hostsOnline": "Hostitelé online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Nepodařilo se aktualizovat stav administrátora", "allSessionsRevoked": "Všechny relace zrušeny", "revokeSessionsFailed": "Nepodařilo se zrušit relace", + "manageUserData": "Správa uživatelských dat", + "backToUsers": "Zpět k uživatelům", + "manageTabAccount": "Účet", + "manageTabHosts": "Hostitelé", + "manageTabCredentials": "Pověření", + "manageTabSnippets": "Úryvky", + "manageTabSessions": "Relace", + "manageTabDanger": "Nebezpečí", + "manageEditorBack": "Zpět k datům uživatele {{username}}", + "dataLockedBadge": "ZAMČENO", + "dataLockedNotice": "Data uživatele {{username}}zůstanou uzamčena až do jeho dalšího přihlášení. Do té doby nelze jeho hostitele, přihlašovací údaje a úryvky zobrazit ani upravovat.", + "resetPasswordTitle": "Obnovit heslo", + "resetPasswordOidcOnly": "Tento uživatel se přihlašuje přes externího poskytovatele a nemá heslo.", + "resetPasswordPlaceholder": "Nové heslo", + "resetPasswordBtn": "Obnovit", + "resetPasswordWorking": "Resetování...", + "resetPasswordSuccess": "Obnovení hesla", + "resetPasswordSuccessWiped": "Reset hesla. Šifrovaná data uživatele byla vymazána.", + "resetPasswordFailed": "Obnovení hesla se nezdařilo", + "resetPasswordConfirmWipe": "{{username}} se od aktualizace šifrování nepřihlásil, takže jeho data nelze obnovit. Resetováním nyní smažete jeho hostitele, přihlašovací údaje a úryvky. Pokračovat?", + "totpSectionTitle": "Dvoufaktorové ověřování", + "totpStatusEnabled": "TOTP je pro tohoto uživatele povolen", + "totpStatusDisabled": "TOTP není pro tohoto uživatele povoleno", + "disableTotp": "Zakázat", + "disableTotpConfirm": "Zakázat dvoufaktorové ověřování pro {{username}}? Budou se moci přihlásit pouze pomocí hesla.", + "totpDisabledSuccess": "TOTP zakázán", + "totpDisableFailed": "Nepodařilo se zakázat TOTP", + "manageApiKeys": "Klíče API", + "apiKeyNamePlaceholder": "Název klíče", + "apiKeyCopyNotice": "Zkopírujte tento klíč nyní, už se vám nezobrazí.", + "noApiKeysForUser": "Žádné API klíče", + "apiKeyDeleteFailed": "Nepodařilo se smazat klíč API", + "exportUserData": "Export dat", + "exportUserDataDesc": "Stáhnout hostitele, přihlašovací údaje a data správce souborů tohoto uživatele ve formátu JSON. Tajné kódy jsou dešifrovány.", + "exportUserDataSuccess": "Exportovaná uživatelská data", + "exportUserDataFailed": "Nepodařilo se exportovat uživatelská data", + "hostsCount": "{{count}} hostitelé", + "addHostForUser": "Přidat hostitele", + "noHostsForUser": "Tento uživatel nemá žádné hostitele", + "connectToHost": "Připojit", + "deleteHostConfirm": "Smazat hostitele „{{name}}“ patřícího do {{username}}?", + "hostDeletedSuccess": "Hostitel smazán", + "hostDeleteFailed": "Nepodařilo se smazat hostitele", + "credentialsCount": "{{count}} pověření", + "addCredentialForUser": "Přidat přihlašovací údaje", + "noCredentialsForUser": "Tento uživatel nemá žádné přihlašovací údaje", + "deleteCredentialConfirm": "Smazat přihlašovací údaje „{{name}}“ patřící uživateli {{username}}?", + "credentialDeletedSuccess": "Přihlašovací údaje smazány", + "credentialDeleteFailed": "Nepodařilo se smazat přihlašovací údaje", + "snippetsCount": "úryvky {{count}}", + "addSnippetForUser": "Přidat úryvek", + "noSnippetsForUser": "Tento uživatel nemá žádné úryvky", + "snippetRequiredFields": "Název a obsah úryvku jsou povinné", + "snippetNamePlaceholder": "Název úryvku", + "snippetContentPlaceholder": "Obsah příkazu", + "snippetFolderPlaceholder": "Složka (volitelné)", + "snippetSaved": "Úryvek uložen", + "snippetSaveFailed": "Uložení úryvku se nezdařilo", + "deleteSnippetConfirm": "Smazat úryvek „{{name}}“ patřící do {{username}}?", + "snippetDeletedSuccess": "Úryvek smazán", + "snippetDeleteFailed": "Nepodařilo se smazat úryvek", + "noSessionsForUser": "Žádné aktivní relace", + "deleteUserDangerDesc": "Trvale smazat {{username}} a všechna jeho data (hostitele, přihlašovací údaje, úryvky, historii). Tuto akci nelze vrátit zpět.", + "deleteUserConfirm": "Trvale smazat {{username}} a všechna jeho data?", + "deleteUserAdminBlocked": "Před smazáním tohoto uživatele odeberte status administrátora.", "createRoleRequired": "Jméno a zobrazované jméno jsou povinné", "createRoleSuccess": "Role „{{name}}“ byla vytvořena", "createRoleFailed": "Vytvoření role se nezdařilo", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Historie příkazů", "hostDefaultsCommandHistoryDesc": "Sledování historie příkazů na nových hostitelích ve výchozím nastavení", "hostDefaultsSaved": "Výchozí nastavení hostitele uloženo", - "hostDefaultsSaveFailed": "Nepodařilo se uložit výchozí nastavení hostitele" + "hostDefaultsSaveFailed": "Nepodařilo se uložit výchozí nastavení hostitele", + "rolePermissions": { + "count": "{{count}} oprávnění", + "editAction": "Upravit oprávnění", + "loadError": "Nepodařilo se načíst katalog oprávnění", + "saved": "Oprávnění role uložena", + "saveError": "Uložení oprávnění role se nezdařilo", + "save": "Uložit", + "saving": "Ukládání..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Selhalo" }, "userProfile": { + "donateTitle": "Udrž Termixe naživu", + "donateDescription": "Termix je zdarma, hostovaný na vlastních platformách a vyvíjí ho jen několik lidí ve svém volném čase. Pokud vám to ušetřilo čas nebo peníze, dar kryptoměn vám pomůže udržet ho v chodu.", + "donateMilestones": "Dary pomáhají financovat čas na výzkum a zjištění, co je potřeba k vytvoření podpory SAML, Kubernetes a Agentů. Sledujte pokrok a darujte.", + "donateButton": "Darujte kryptoměny", "storageModeLocal": "Prohlížeč", "storageModeCloud": "Databáze", "storageModeDescription": "Prohlížeč ukládá nastavení pouze v tomto prohlížeči. Databáze se synchronizuje se serverem a načítá se na jakémkoli zařízení.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Na", "twoFaOff": "Vypnuto", "versionLabel": "Verze", + "betaProgramTitle": "Beta program", + "betaProgramDescription": "Vyzkoušejte připravované funkce včas s týdenním aktualizačním tagem :beta v Dockeru. Nestabilní, není určeno pro produkční prostředí.", + "betaProgramFeedback": "Našli jste chybu? Nahlaste ji zde.", + "betaProgramLearnMore": "Zjistěte více", "deleteAccount": "Smazat účet", "deleteAccountDescription": "Trvale smazat váš účet", "changeServerDescription": "Přepněte na jiný backendový server Termix", diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index 455b75fa..afa4b973 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Kommaseparerede navnemønstre, der gør det muligt for Docker at matche gæster", "proxmoxPreferredRanges": "Foretrukne IP-intervaller", "proxmoxPreferredRangesDesc": "Kommaseparerede præfikser i prioriteret rækkefølge for IP-valg, når en gæst har flere grænseflader", + "proxmoxAutoSync": "Automatisk synkronisering af gæster", + "proxmoxAutoSyncDesc": "Opdag periodisk denne Proxmox-node og opret eller opdater importerede gæsteværter, mens din session er låst op.", + "proxmoxSyncInterval": "Synkroniseringsinterval (minutter)", + "proxmoxSyncIntervalDesc": "Minimum 5 minutter. Planlæggeren springer låste brugersessioner over.", + "proxmoxMarkMissing": "Markér manglende gæster", + "proxmoxMarkMissingDesc": "Tilføj et proxmox-missing-tag, når en tidligere importeret gæst forsvinder, i stedet for at slette den.", + "proxmoxLastSync": "Sidste synkronisering", + "proxmoxLastSyncNever": "Ikke synkroniseret endnu", + "proxmoxLastSyncNoResult": "Ingen resultatdetaljer", + "proxmoxLastSyncSummary": "{{created}} oprettet, {{updated}} opdateret, {{markedMissing}} mangler, {{skipped}} sprunget over", + "proxmoxLastSyncStatus": { + "success": "Succes", + "error": "Mislykkedes", + "pending": "Indtil" + }, "proxmoxDiscoverAction": "Opdag og importer Proxmox-gæster", "proxmoxImportTitle": "Importer fra Proxmox", "proxmoxSelectHost": "Vælg en Proxmox-vært…", @@ -1167,7 +1182,57 @@ "shareHost": "Del vært", "shareHostTitle": "Del: {{name}}", "sharing": { - "requiresCredential": "Denne vært skal bruge legitimationsoplysninger for at aktivere deling. Rediger værten, og tildel først legitimationsoplysninger." + "loadError": "Delingsdataene kunne ikke indlæses. Prøv igen.", + "shareWithSection": "Del med", + "usersTab": "Brugere", + "rolesTab": "Roller", + "searchPlaceholder": "Søg efter brugere eller roller...", + "noMatches": "Ingen matches fundet", + "permissionLevelLabel": "Tilladelsesniveau", + "levels": { + "connect": { + "label": "Forbinde", + "description": "Kun åbne sessioner: terminal, fjernskrivebord, filhåndtering, tunneler og Docker. Ingen adgang til værtkonfigurationen." + }, + "view": { + "label": "Udsigt", + "description": "Opret forbindelse, plus se værtkonfigurationen. Hemmeligheder vises aldrig." + }, + "edit": { + "label": "Redigere", + "description": "Vis og rediger værten. Hemmeligheder kan erstattes, men aldrig læses; tildeling af legitimationsoplysninger forbliver kun for ejeren." + }, + "manage": { + "label": "Styre", + "description": "Rediger, plus del værten med andre, skift tilladelsesniveauer og tilbagekald adgang." + } + }, + "expiryLabel": "Udløb af adgang", + "expiry": { + "never": "Aldrig", + "oneHour": "1 time", + "oneDay": "24 timer", + "sevenDays": "7 dage", + "thirtyDays": "30 dage", + "custom": "Skik" + }, + "customHoursPlaceholder": "Timer indtil adgangen udløber", + "shareButton": "Dele", + "shareWithCount": "Del ({{count}})", + "currentAccess": "Nuværende adgang", + "noAccessEntries": "Denne vært er ikke blevet delt endnu", + "grantedBy": "Tildelt af", + "expires": "Udløber", + "expired": "Udløbet", + "never": "Aldrig", + "revoke": "Tilbagekald", + "accessUpdated": "Adgang opdateret", + "accessUpdateFailed": "Adgang kunne ikke opdateres", + "sharedBadge": "Delt", + "sharedBadgeTooltip": "Delt af {{owner}} ({{level}} adgang)", + "viewOnlyBanner": "Denne vært deles med dig af {{owner}} med læseadgang. Konfigurationen er skrivebeskyttet.", + "sharedEditBanner": "Denne vært deles med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den rigtige vært; godkendelsesreferencer kan kun ændres af ejeren.", + "ownerOnlyControl": "Kun værtsejeren kan ændre dette" }, "guac": { "connection": "Forbindelse", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tilbagetast", "saveHostFirst": "Gem værten først.", "sharingOptionsAfterSave": "Delingsmuligheder er tilgængelige, når værten er blevet gemt.", - "sharingLoadError": "Kunne ikke indlæse delingsdata. Tjek din forbindelse, og prøv igen.", - "shareHostSection": "Del vært", - "shareWithUser": "Del med bruger", - "shareWithRole": "Del med rolle", - "selectUser": "Vælg bruger", - "selectRole": "Vælg rolle", - "selectUserOption": "Vælg en bruger...", - "selectRoleOption": "Vælg en rolle...", "permissionLevel": "Tilladelsesniveau", - "expiresInHours": "Udløber om (timer)", - "noExpiryPlaceholder": "Lad være tomt for ingen udløbsdato", - "shareBtn": "Dele", - "currentAccess": "Nuværende adgang", "typeHeader": "Type", "targetHeader": "Mål", "permissionHeader": "Tilladelse", - "grantedByHeader": "Tildelt af", - "expiresHeader": "Udløber", - "noAccessEntries": "Ingen adgangsposter endnu.", - "expiredLabel": "Udløbet", - "neverLabel": "Aldrig", - "revokeBtn": "Tilbagekald", "cancelBtn": "Ophæve", "savingBtn": "Gemmer...", "updateHostBtn": "Opdater vært", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Adgangskodelogin er deaktiveret. Brug en adgangsnøgle eller en ekstern godkendelsesudbyder.", "signInWithPasskey": "Log ind med adgangsnøgle", "passkeyLoginFailed": "Login med adgangsnøgle mislykkedes", - "attemptsRemaining": "{{count}} forsøg tilbage" + "attemptsRemaining": "{{count}} forsøg tilbage", + "confirmResetDataWipe": "Denne konto er ikke logget ind siden krypteringsopgraderingen, så dens gemte data kan ikke gendannes uden den gamle adgangskode. Nulstilling vil permanent slette dens værter, legitimationsoplysninger og kodestykker. Vil du fortsætte?" }, "hostKey": { "verifyNewHost": "Bekræft SSH-værtsnøgle", @@ -2323,6 +2371,13 @@ "serviceLinks": "Servicelinks", "homepagePreview": "Forhåndsvisning af hjemmeside" }, + "donation": { + "title": "Kan du lide Termix?", + "body": "Termix er gratis og open source, bygget og vedligeholdt af et team på to personer i vores fritid. Hvis det erstatter et kommercielt værktøj, du ellers skulle betale for, hjælper en donation med at dække hostingomkostninger og holde udviklingen i gang. I øjeblikket er donationer kun krypto.", + "milestones": "Donationer hjælper også med at finansiere den tid, der skal bruges til at undersøge og lære, hvad der er nødvendigt for at udvikle funktioner som SAML og Kubernetes-support. Se status på donationssiden.", + "cta": "Donér", + "dismiss": "Måske senere" + }, "dashboardTab": { "stable": "STABIL", "hostsOnline": "Værter online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Kunne ikke opdatere administratorstatus", "allSessionsRevoked": "Alle sessioner tilbagekaldt", "revokeSessionsFailed": "Kunne ikke tilbagekalde sessioner", + "manageUserData": "Administrer brugerdata", + "backToUsers": "Tilbage til brugerne", + "manageTabAccount": "Konto", + "manageTabHosts": "Værter", + "manageTabCredentials": "Akkreditiver", + "manageTabSnippets": "Uddrag", + "manageTabSessions": "Sessioner", + "manageTabDanger": "Fare", + "manageEditorBack": "Tilbage til {{username}}s data", + "dataLockedBadge": "LÅST", + "dataLockedNotice": "{{username}}s data forbliver låst, indtil de logger ind næste gang. Deres værter, legitimationsoplysninger og kodestykker kan ikke ses eller redigeres indtil da.", + "resetPasswordTitle": "Nulstil adgangskode", + "resetPasswordOidcOnly": "Denne bruger logger ind via en ekstern udbyder og har ingen adgangskode.", + "resetPasswordPlaceholder": "Ny adgangskode", + "resetPasswordBtn": "Nulstil", + "resetPasswordWorking": "Nulstiller...", + "resetPasswordSuccess": "Nulstilling af adgangskode", + "resetPasswordSuccessWiped": "Nulstilling af adgangskode. Brugerens krypterede data blev slettet.", + "resetPasswordFailed": "Kunne ikke nulstille adgangskoden", + "resetPasswordConfirmWipe": "{{username}} har ikke logget ind siden krypteringsopgraderingen, så deres data kan ikke gendannes. Nulstilling nu vil slette deres værter, legitimationsoplysninger og kodestykker. Vil du fortsætte?", + "totpSectionTitle": "Tofaktorgodkendelse", + "totpStatusEnabled": "TOTP er aktiveret for denne bruger", + "totpStatusDisabled": "TOTP er ikke aktiveret for denne bruger", + "disableTotp": "Deaktiver", + "disableTotpConfirm": "Deaktiver tofaktorgodkendelse for {{username}}? De vil kun kunne logge ind med deres adgangskode.", + "totpDisabledSuccess": "TOTP deaktiveret", + "totpDisableFailed": "Kunne ikke deaktivere TOTP", + "manageApiKeys": "API-nøgler", + "apiKeyNamePlaceholder": "Nøglenavn", + "apiKeyCopyNotice": "Kopier denne nøgle nu, den vil ikke blive vist igen.", + "noApiKeysForUser": "Ingen API-nøgler", + "apiKeyDeleteFailed": "Kunne ikke slette API-nøglen", + "exportUserData": "Dataeksport", + "exportUserDataDesc": "Download denne brugers værter, legitimationsoplysninger og filhåndteringsdata som JSON. Hemmelighederne dekrypteres.", + "exportUserDataSuccess": "Brugerdata eksporteret", + "exportUserDataFailed": "Kunne ikke eksportere brugerdata", + "hostsCount": "{{count}} værter", + "addHostForUser": "Tilføj vært", + "noHostsForUser": "Denne bruger har ingen værter", + "connectToHost": "Forbinde", + "deleteHostConfirm": "Slet værten \"{{name}}\" der tilhører {{username}}?", + "hostDeletedSuccess": "Vært slettet", + "hostDeleteFailed": "Kunne ikke slette værten", + "credentialsCount": "{{count}} legitimationsoplysninger", + "addCredentialForUser": "Tilføj legitimationsoplysninger", + "noCredentialsForUser": "Denne bruger har ingen loginoplysninger", + "deleteCredentialConfirm": "Slet legitimationsoplysningerne \"{{name}}\", der tilhører {{username}}?", + "credentialDeletedSuccess": "Legitimationsoplysninger slettet", + "credentialDeleteFailed": "Kunne ikke slette legitimationsoplysninger", + "snippetsCount": "{{count}} uddrag", + "addSnippetForUser": "Tilføj uddrag", + "noSnippetsForUser": "Denne bruger har ingen uddrag", + "snippetRequiredFields": "Navn og indhold på uddrag er påkrævet", + "snippetNamePlaceholder": "Snippetnavn", + "snippetContentPlaceholder": "Kommandoindhold", + "snippetFolderPlaceholder": "Mappe (valgfrit)", + "snippetSaved": "Uddrag gemt", + "snippetSaveFailed": "Kunne ikke gemme uddraget", + "deleteSnippetConfirm": "Slet uddraget \"{{name}}\" der tilhører {{username}}?", + "snippetDeletedSuccess": "Uddrag slettet", + "snippetDeleteFailed": "Kunne ikke slette uddraget", + "noSessionsForUser": "Ingen aktive sessioner", + "deleteUserDangerDesc": "Slet permanent {{username}} og alle deres data (hosts, legitimationsoplysninger, kodesnippets, historik). Dette kan ikke fortrydes.", + "deleteUserConfirm": "Vil du slette {{username}} og alle deres data permanent?", + "deleteUserAdminBlocked": "Fjern administratorstatus, før du sletter denne bruger.", "createRoleRequired": "Navn og visningsnavn er påkrævet", "createRoleSuccess": "Rollen \"{{name}}\" blev oprettet", "createRoleFailed": "Kunne ikke oprette rollen", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Kommandohistorik", "hostDefaultsCommandHistoryDesc": "Spor kommandohistorik på nye værter som standard", "hostDefaultsSaved": "Værtsstandarder gemt", - "hostDefaultsSaveFailed": "Kunne ikke gemme værtens standardindstillinger" + "hostDefaultsSaveFailed": "Kunne ikke gemme værtens standardindstillinger", + "rolePermissions": { + "count": "{{count}} tilladelser", + "editAction": "Rediger tilladelser", + "loadError": "Kunne ikke indlæse tilladelseskataloget", + "saved": "Rolletilladelser gemt", + "saveError": "Rolletilladelser kunne ikke gemmes", + "save": "Spare", + "saving": "Gemmer..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Mislykkedes" }, "userProfile": { + "donateTitle": "Hold Termix i live", + "donateDescription": "Termix er gratis, selvhostet og bygget af kun få personer i deres fritid. Hvis det har sparet dig tid eller penge, kan en kryptodonation hjælpe med at holde det kørende.", + "donateMilestones": "Donationer hjælper med at finansiere tiden til at undersøge og lære, hvad der er nødvendigt for at opbygge SAML-, Kubernetes- og agentsupport. Se fremskridtene, og donér.", + "donateButton": "Donér krypto", "storageModeLocal": "Browser", "storageModeCloud": "Database", "storageModeDescription": "Browseren gemmer kun indstillinger i denne browser. Databasen synkroniseres med serveren og indlæses på enhver enhed.", @@ -3011,6 +3144,10 @@ "twoFaOn": "På", "twoFaOff": "Slukket", "versionLabel": "Version", + "betaProgramTitle": "Betaprogram", + "betaProgramDescription": "Prøv kommende funktioner tidligt med det ugentlige rullende :beta Docker-tag. Ustabil, ikke til produktion.", + "betaProgramFeedback": "Har du fundet en fejl? Rapportér den her.", + "betaProgramLearnMore": "Lær mere", "deleteAccount": "Slet konto", "deleteAccountDescription": "Slet din konto permanent", "changeServerDescription": "Skift til en anden Termix backend-server", diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index 5aece87b..0de5e9ad 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -2,10 +2,10 @@ "termixId": { "title": "Termix-ID", "loadFailed": "Termix-ID konnte nicht geladen werden", - "claimTitle": "Fordern Sie Ihre Termix-ID an.", + "claimTitle": "Termix-ID beanspruchen", "claimIntro": "Wählen Sie einen eindeutigen Benutzernamen. Ihre öffentlichen SSH-Schlüssel werden unter einer öffentlichen URL veröffentlicht, die Sie der authorized_keys-Datei eines beliebigen Servers hinzufügen können.", - "handleLabel": "Handhaben", - "handlePlaceholder": "Alice", + "handleLabel": "Handle", + "handlePlaceholder": "alice", "checking": "Überprüfung…", "available": "Verfügbar", "taken": "Bereits vergeben", @@ -15,26 +15,26 @@ "create": "Termix-ID erstellen", "created": "Termix-ID erstellt", "createFailed": "Termix-ID konnte nicht erstellt werden", - "deleteConfirm": "Ihre Termix-ID und alle veröffentlichten Schlüssel löschen? Die von Ihnen bereitgestellten Server behalten die Schlüssel, bis Sie sie manuell entfernen.", + "deleteConfirm": "Ihre Termix-ID und alle veröffentlichten Schlüssel löschen? Die von Ihnen eingerichteten Server behalten die Schlüssel, bis Sie sie manuell entfernen.", "deleted": "Termix-ID gelöscht", "deleteFailed": "Termix-ID konnte nicht gelöscht werden", "copyFailed": "Kopieren fehlgeschlagen", "resolverUrlLabel": "Öffentliche Resolver-URL", "provisionLabel": "Einen Server bereitstellen", - "publishTitle": "Veröffentlichen Sie einen öffentlichen Schlüssel", + "publishTitle": "Öffentlichen Schlüssel veröffentlichen", "generate": "Erzeugen", "generateTooltip": "Generieren Sie ein Ed25519-Schlüsselpaar – der öffentliche Schlüssel wird veröffentlicht und der private Schlüssel auf Ihr Gerät heruntergeladen.", - "saveToVault": "In Anmeldeinformationen speichern", + "saveToVault": "Unter Zugangsdaten speichern", "keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host", - "labelPlaceholder": "Etikett (optional)", + "labelPlaceholder": "Bezeichnung (optional)", "add": "Hinzufügen", - "importFromCredential": "Oder importieren Sie die Anmeldeinformationen aus einem gespeicherten Verzeichnis.", + "importFromCredential": "Oder aus gespeicherten Zugangsdaten importieren", "keyPublished": "Schlüssel veröffentlicht", "addKeyFailed": "Schlüssel konnte nicht hinzugefügt werden", - "generatedSaved": "Das Schlüsselpaar wurde generiert und in Ihrem Anmeldeinformationsspeicher abgelegt. Der private Schlüssel wurde ebenfalls heruntergeladen.", + "generatedSaved": "Schlüsselpaar generiert und im Zugangsdaten-Tresor gespeichert. Der private Schlüssel wurde ebenfalls heruntergeladen.", "generatedOnly": "Schlüsselpaar generiert – privater Schlüssel heruntergeladen (wird nur einmal angezeigt).", "generateFailed": "Schlüssel konnte nicht generiert werden", - "imported": "Schlüssel aus Anmeldeinformationen importiert", + "imported": "Schlüssel aus Zugangsdaten importiert", "importFailed": "Schlüssel konnte nicht importiert werden", "noKeys": "Es wurden noch keine öffentlichen Schlüssel veröffentlicht.", "keysTitle": "Veröffentlichte Schlüssel", @@ -43,9 +43,9 @@ "keyRemoved": "Schlüssel entfernt", "removeKeyFailed": "Schlüssel konnte nicht entfernt werden", "updateKeyFailed": "Schlüssel konnte nicht aktualisiert werden", - "fromVault": "Aus dem Anmeldeinformationsspeicher", + "fromVault": "Aus dem Zugangsdaten-Tresor", "linkedToTermixId": "Veröffentlicht über Termix ID", - "selectCredential": "Wählen Sie eine Anmeldeinformation aus...", + "selectCredential": "Zugangsdaten auswählen...", "import": "Import", "caTitle": "Zertifizierungsstelle", "caIntro": "Vertrauen Sie dieser Zertifizierungsstelle auf einem Server, dann akzeptiert er jedes von Ihnen signierte Zertifikat. Durch regelmäßiges Rotieren können Sie alle Zertifikate gleichzeitig widerrufen; Zertifikate laufen außerdem automatisch ab.", @@ -53,11 +53,11 @@ "caEnabled": "CA aktiviert", "caCreateFailed": "CA konnte nicht aktiviert werden", "caPublicKeyLabel": "CA-öffentlicher Schlüssel", - "caTrustLabel": "Vertrauenswürdigkeit auf einem Server (als Root ausführen)", - "caRotate": "Drehen", - "caRotateConfirm": "Die Zertifizierungsstelle wechseln? Alle von ihr unterzeichneten Zertifikate verlieren ihre Gültigkeit und müssen neu ausgestellt werden.", + "caTrustLabel": "Auf einem Server als vertrauenswürdig hinterlegen (als Root ausführen)", + "caRotate": "Wechseln", + "caRotateConfirm": "Zertifizierungsstelle wechseln? Alle von ihr signierten Zertifikate werden nicht mehr akzeptiert und müssen neu ausgestellt werden.", "caRotated": "Zertifizierungsstelle gewechselt – vorherige Zertifikate widerrufen", - "caRotateFailed": "CA konnte nicht gedreht werden.", + "caRotateFailed": "CA konnte nicht rotiert werden", "caDelete": "CA entfernen", "caDeleteConfirm": "Die Zertifizierungsstelle entfernen?", "caDeleted": "CA entfernt", @@ -77,29 +77,29 @@ "upload": "Hochladen", "keyPassword": "Schlüsselpasswort", "sshKey": "SSH-Schlüssel", - "uploadPrivateKeyFile": "Laden Sie die private Schlüsseldatei hoch.", - "searchCredentials": "Anmeldeinformationen suchen...", - "addCredential": "Anmeldeinformationen hinzufügen", + "uploadPrivateKeyFile": "Datei mit privatem Schlüssel hochladen", + "searchCredentials": "Zugangsdaten suchen...", + "addCredential": "Zugangsdaten hinzufügen", "caCertificate": "CA-Zertifikat (-cert.pub)", "caCertificateDescription": "Optional: Laden Sie die von der Zertifizierungsstelle signierte Zertifikatsdatei (z. B. id_ed25519-cert.pub) hoch oder fügen Sie sie ein. Dies ist erforderlich, wenn Ihr SSH-Server zertifikatbasierte Authentifizierung verwendet.", - "uploadCertFile": "Datei hochladen (cert.pub)", - "clearCert": "Klar", + "uploadCertFile": "Datei „-cert.pub“ hochladen", + "clearCert": "Entfernen", "certLoaded": "Zertifikat geladen", "certPublicKeyLabel": "CA-Zertifikat", "certTypeLabel": "Zertifikatstyp", "pasteOrUploadCert": "Fügen Sie ein -cert.pub-Zertifikat ein oder laden Sie es hoch...", "hasCaCert": "Besitzt CA-Zertifikat", "noCaCert": "Kein CA-Zertifikat", - "noPublicKeyAvailable": "Kein öffentlicher Schlüssel verfügbar. Öffnen Sie zuerst den Anmeldeinformationseditor.", - "deployCommandCopied": "Deploy-Befehl kopiert", - "sortCredentials": "Anmeldeinformationen sortieren", + "noPublicKeyAvailable": "Kein öffentlicher Schlüssel verfügbar. Öffnen Sie zuerst den Zugangsdaten-Editor.", + "deployCommandCopied": "Bereitstellungsbefehl kopiert", + "sortCredentials": "Zugangsdaten sortieren", "sortDefault": "Standardreihenfolge", "sortNameAsc": "Name (A → Z)", "sortNameDesc": "Name (Z → A)", "sortUsernameAsc": "Benutzername (A → Z)", "sortUsernameDesc": "Benutzername (Z → A)", - "filterCredentials": "Filteranmeldeinformationen", - "filterClearAll": "Filter löschen", + "filterCredentials": "Zugangsdaten filtern", + "filterClearAll": "Filter zurücksetzen", "filterTypeGroup": "Typ", "filterTypePassword": "Passwort", "filterTypeKey": "SSH-Schlüssel", @@ -111,21 +111,21 @@ "editWidget": "Widget bearbeiten", "deleteWidget": "Löschen", "widgetTypes": "Widget-Typen", - "serviceLink": "Service Link", + "serviceLink": "Service-Link", "folder": "Ordner", "clock": "Uhr", - "notes": "Anmerkungen", + "notes": "Notizen", "hostStatus": "Hoststatus", "bookmarkList": "Lesezeichen", "zoomIn": "Vergrößern", - "zoomOut": "Herauszoomen", + "zoomOut": "Verkleinern", "resetView": "Ansicht zurücksetzen", - "lockLayout": "Schlossbelegung", + "lockLayout": "Layout sperren", "unlockLayout": "Layout entsperren", - "noWidgets": "Klicken Sie mit der rechten Maustaste oder klicken Sie auf +, um Ihr erstes Widget hinzuzufügen.", + "noWidgets": "Klicken Sie mit der rechten Maustaste oder auf +, um Ihr erstes Widget hinzuzufügen.", "openFullView": "Vollansicht öffnen", "previewTitle": "Startseitenvorschau", - "cancel": "Stornieren", + "cancel": "Abbrechen", "save": "Speichern", "title_label": "Titel", "widgetTitlePlaceholder": "Widget-Titel (optional)", @@ -139,15 +139,15 @@ "expanded": "Standardmäßig erweitert", "timezone": "Zeitzone", "showSeconds": "Sekunden anzeigen", - "format12h": "12 Stunden", - "format24h": "24 Stunden", + "format12h": "12-Stunden-Format", + "format24h": "24-Stunden-Format", "content": "Inhalt", "backgroundColor": "Hintergrundfarbe", - "host": "Gastgeber", - "showMetrics": "Kennzahlen anzeigen", + "host": "Host", + "showMetrics": "Metriken anzeigen", "links": "Links", "addLink": "Link hinzufügen", - "linkLabel": "Etikett", + "linkLabel": "Bezeichnung", "linkUrl": "URL", "removeLink": "Entfernen", "categoryLinks": "Links", @@ -166,7 +166,7 @@ "showForecast": "3-Tage-Vorhersage anzeigen", "scrolling": "Scrollen zulassen", "feedUrl": "Feed-URL", - "maxItems": "Maximale Anzahl an Gegenständen", + "maxItems": "Maximale Anzahl an Einträgen", "showDescription": "Beschreibung anzeigen", "widgetWeatherName": "Wetter", "widgetWeatherDesc": "Live-Wetterdaten für jeden beliebigen Ort", @@ -178,12 +178,12 @@ "showDisk": "Festplattennutzung anzeigen", "addToFolder": "Widget zum Ordner hinzufügen", "noHostSelected": "Kein Host ausgewählt", - "metricsNotAvailable": "Kennzahlen nicht verfügbar", + "metricsNotAvailable": "Metriken nicht verfügbar", "selectHost": "Wählen Sie einen Host aus...", "displayedMetrics": "Angezeigte Metriken", "metricCpu": "CPU", - "metricMemory": "Erinnerung", - "metricDisk": "Scheibe", + "metricMemory": "Arbeitsspeicher", + "metricDisk": "Datenträger", "metricUptime": "Betriebszeit", "metricSystem": "System", "metricOs": "Betriebssystem", @@ -192,14 +192,14 @@ "metricNetwork": "Netzwerk", "metricProcesses": "Prozesse", "metricProcessesTotal": "gesamt", - "metricProcessesRunning": "läuft", + "metricProcessesRunning": "aktiv", "categoryMonitoring": "Überwachung", - "loading": "Laden...", + "loading": "Wird geladen...", "noData": "Keine Daten verfügbar", - "allClear": "Alles klar", - "acknowledgeAlert": "Anerkennen", + "allClear": "Alles in Ordnung", + "acknowledgeAlert": "Bestätigen", "noPingUrls": "Keine URLs konfiguriert", - "pingLabel": "Etikett", + "pingLabel": "Bezeichnung", "addPingUrl": "URL hinzufügen", "showLatency": "Latenz anzeigen", "refreshInterval": "Aktualisierungsintervall", @@ -207,7 +207,7 @@ "filterActivityTypes": "Filtertypen", "filterTypesHint": "Lassen Sie das Feld leer, um alle anzuzeigen", "showTimestamp": "Zeitstempel anzeigen", - "uptimeUnavailable": "Verfügbarkeit nicht verfügbar", + "uptimeUnavailable": "Betriebszeit nicht verfügbar", "uptimeLabel": "Betriebszeit", "overviewVersion": "Version", "overviewUpdate": "Aktuell", @@ -215,32 +215,32 @@ "overviewDatabase": "Datenbank", "overviewUptime": "Betriebszeit", "noHosts": "Keine Hosts konfiguriert", - "hostGridHosts": "Gastgeber", + "hostGridHosts": "Hosts", "hostGridAllHint": "Lassen Sie dieses Feld leer, um alle Hosts anzuzeigen.", "columns": "Spalten", "showIp": "IP-Adresse anzeigen", "connectionType": "Verbindungstyp", "layout": "Layout", "showStatus": "Status anzeigen", - "showHostName": "Name des Moderators", + "showHostName": "Hostnamen anzeigen", "noDockerActivity": "Keine Docker-Aktivität", "noActivity": "Keine Aktivität", - "showAcknowledged": "Bestätigung anzeigen", + "showAcknowledged": "Bestätigte anzeigen", "showCurrentValue": "Aktuellen Wert anzeigen", - "chartMetric": "Metrisch", - "metricRange": "Reichweite", + "chartMetric": "Metrik", + "metricRange": "Zeitraum", "widgetMetricsChartName": "Metriken-Diagramm", "widgetMetricsChartDesc": "Historisches Diagramm zu CPU, Arbeitsspeicher, Festplatte oder Netzwerk für einen Host", - "widgetHostGridName": "Host Grid", + "widgetHostGridName": "Host-Raster", "widgetHostGridDesc": "Rasteransicht der SSH-Hoststatus", - "widgetAlertFeedName": "Benachrichtigungsfeed", - "widgetAlertFeedDesc": "Live-Alarmauslösungen mit Bestätigungsunterstützung", + "widgetAlertFeedName": "Warnmeldungsfeed", + "widgetAlertFeedDesc": "Aktive Warnmeldungen mit Bestätigungsfunktion", "widgetPingStatusName": "Ping-Status", "widgetPingStatusDesc": "HTTP-Ping-Status für eine oder mehrere URLs", "widgetRecentActivityName": "Aktuelle Aktivitäten", "widgetRecentActivityDesc": "Scrollbarer Feed der letzten Termix-Aktivitäten", - "widgetTermixUptimeName": "Termix-Verfügbarkeit", - "widgetTermixUptimeDesc": "Live-Verfügbarkeitszähler für den Termix-Server", + "widgetTermixUptimeName": "Termix-Betriebszeit", + "widgetTermixUptimeDesc": "Live-Zähler für die Betriebszeit des Termix-Servers", "widgetSystemOverviewName": "Systemübersicht", "widgetSystemOverviewDesc": "Termix-Version, Datenbankzustand und Verfügbarkeit auf einen Blick", "widgetSshQuickConnectName": "SSH-Schnellverbindung", @@ -258,50 +258,50 @@ "widgetImageWidgetName": "Bild", "widgetImageWidgetDesc": "Ein Bild von einer URL anzeigen", "widgetMarkdownNotesName": "Markdown-Notizen", - "widgetMarkdownNotesDesc": "Reichhaltige Notizen mit Inline-Markdown-Rendering", + "widgetMarkdownNotesDesc": "Formatierte Notizen mit Inline-Markdown-Darstellung", "widgetCustomApiName": "Benutzerdefinierte API", "widgetCustomApiDesc": "Daten von beliebigen JSON-APIs abrufen und anzeigen", - "widgetServiceGridName": "Service Grid", + "widgetServiceGridName": "Service-Raster", "widgetServiceGridDesc": "Ein konfigurierbares Raster von Service-Kachel-Links", "widgetDashboardLinksName": "Dashboard-Links", - "widgetDashboardLinksDesc": "Zeigen Sie Ihre konfigurierten Service-Links vom Dashboard aus an.", - "widgetSearchLinksName": "Suchverknüpfungen", - "widgetSearchLinksDesc": "Schnellsuche-Buttons mit integrierter Eingabe", + "widgetDashboardLinksDesc": "Zeigen Sie die konfigurierten Service-Links aus Ihrem Dashboard an.", + "widgetSearchLinksName": "Such-Shortcuts", + "widgetSearchLinksDesc": "Schaltflächen für die Schnellsuche mit integriertem Eingabefeld", "widgetLinkTreeName": "Linkbaum", "widgetLinkTreeDesc": "Gruppierte Linkabschnitte mit Überschriften", "calMon": "Mo", - "calTue": "Tu", - "calWed": "Wir", - "calThu": "Th", + "calTue": "Di", + "calWed": "Mi", + "calThu": "Do", "calFri": "Fr", "calSat": "Sa", - "calSun": "Su", + "calSun": "So", "startOnMonday": "Die Woche beginnt am Montag.", "countdownNoDate": "Kein Zieldatum festgelegt", - "countdownPast": "Die Veranstaltung hat bereits stattgefunden.", + "countdownPast": "Ereignis ist vorbei", "countdownDays": "Tage", - "countdownHours": "Stunden", + "countdownHours": "Std.", "countdownMinutes": "min", - "countdownSeconds": "Sekunde", - "countdownLabel": "Etikett", + "countdownSeconds": "Sek.", + "countdownLabel": "Bezeichnung", "countdownLabelPlaceholder": "z. B. Starttag", - "countdownShowDays": "Showtage", - "countdownShowHours": "Öffnungszeiten", + "countdownShowDays": "Tage anzeigen", + "countdownShowHours": "Stunden anzeigen", "targetDate": "Zieldatum", "searchEngine": "Suchmaschine", "customSearchUrl": "Benutzerdefinierte Such-URL", "searchPlaceholder": "Suchen...", - "searchGo": "Gehen", + "searchGo": "Suchen", "searchPlaceholderLabel": "Platzhaltertext", "searchPlaceholderHint": "Im Suchfeld angezeigter Text", "openInNewTab": "In neuem Tab öffnen", "searchQueryPlaceholder": "Suchanfrage eingeben...", - "noSearchShortcuts": "Keine Tastenkombinationen konfiguriert", + "noSearchShortcuts": "Keine Suchverknüpfungen konfiguriert", "addSearchShortcut": "Verknüpfung hinzufügen", "fontSize": "Schriftgröße", "textAlign": "Textausrichtung", "fontWeight": "Schriftstärke", - "clearColor": "Klare Farbe", + "clearColor": "Farbe zurücksetzen", "imageFit": "Bildanpassung", "imageLinkUrl": "Link-URL", "noImage": "Keine Bild-URL festgelegt", @@ -311,25 +311,25 @@ "displayMode": "Anzeigemodus", "displayField": "Anzeigefeld", "jsonPath": "JSON-Pfad", - "customApiLabel": "Etikett", + "customApiLabel": "Bezeichnung", "customApiLabelPlaceholder": "z. B. Temperatur", "customApiUnit": "Einheit", "customApiNoUrl": "Keine API-URL konfiguriert", - "customApiError": "Fehler beim Abrufen", + "customApiError": "Abruf fehlgeschlagen", "customApiNotArray": "Die Antwort ist kein Array.", "addService": "Dienst hinzufügen", - "showLabels": "Etiketten anzeigen", + "showLabels": "Bezeichnungen anzeigen", "iconSize": "Symbolgröße", "noDashboardLinks": "Es sind keine Dashboard-Links konfiguriert.", - "noLimit": "Keine Grenzen", + "noLimit": "Unbegrenzt", "sectionHeading": "Abschnittsüberschrift", "addSection": "Abschnitt hinzufügen", "compactMode": "Kompaktmodus", "showDetailed": "Details anzeigen", "selectHosts": "Hosts auswählen", - "allHosts": "Alle Gastgeber", + "allHosts": "Alle Hosts", "listLayout": "Liste", - "gridLayout": "Netz", + "gridLayout": "Raster", "terminal": "Terminal", "files": "Dateien", "docker": "Docker", @@ -337,8 +337,8 @@ "range1h": "1 Stunde", "range6h": "6 Stunden", "range24h": "24 Stunden", - "metricNetRx": "Net Download", - "metricNetTx": "Net Upload", + "metricNetRx": "Netzwerk-Download", + "metricNetTx": "Netzwerk-Upload", "severityFilter": "Schweregradfilter", "filterAll": "Alle", "accentColor": "Akzentfarbe", @@ -346,9 +346,9 @@ "widgetSshTerminalDesc": "Ein Inline-SSH-Terminal, das mit einem konfigurierten Host verbunden ist", "sshTerminalNoHost": "Kein Host konfiguriert", "sshTerminalConnect": "Verbinden", - "sshTerminalAutoConnect": "Automatische Verbindung bei Last", + "sshTerminalAutoConnect": "Beim Laden automatisch verbinden", "widgetQuickConnectName": "Schnellverbindung", - "widgetQuickConnectDesc": "Ein-Klick-Startschaltflächen für jeden Verbindungstyp über Ihre Hosts hinweg.", + "widgetQuickConnectDesc": "Schaltflächen zum Öffnen beliebiger Verbindungstypen auf Ihren Hosts mit nur einem Klick", "connectionTypes": "Verbindungstypen", "connType_terminal": "Terminal", "connType_files": "Dateimanager", @@ -360,7 +360,7 @@ "connType_telnet": "Telnet", "widgetFileManagerName": "Dateimanager", "widgetFileManagerDesc": "Eingebetteter SFTP-Dateimanager für einen konfigurierten Host", - "widgetDockerName": "Docker Manager", + "widgetDockerName": "Docker-Manager", "widgetDockerDesc": "Eingebetteter Docker-Container-Manager für einen konfigurierten Host", "widgetTunnelName": "Tunnelmanager", "widgetTunnelDesc": "Eingebetteter SSH-Tunnelmanager für einen konfigurierten Host", @@ -411,14 +411,14 @@ "online": "Online", "offline": "Offline", "unknown": "Unbekannt", - "continue": "Weitermachen", + "continue": "Weiter", "maintenance": "Wartung", - "degraded": "Abgebaut", + "degraded": "Beeinträchtigt", "error": "Fehler", "warning": "Warnung", "unsavedChanges": "Nicht gespeicherte Änderungen", - "dismiss": "Zurückweisen", - "loading": "Laden...", + "dismiss": "Ausblenden", + "loading": "Wird geladen...", "optional": "Optional", "connect": "Verbinden", "copied": "Kopiert", @@ -432,37 +432,37 @@ "failedToFetchUpdateInfo": "Aktualisierungsinformationen konnten nicht abgerufen werden.", "preRelease": "Vorabversion", "noReleasesFound": "Keine Veröffentlichungen gefunden.", - "cancel": "Stornieren", + "cancel": "Abbrechen", "username": "Benutzername", - "login": "Login", + "login": "Anmelden", "logout": "Abmelden", "register": "Registrieren", "password": "Passwort", "confirmPassword": "Passwort bestätigen", "back": "Zurück", "save": "Speichern", - "saving": "Speichern...", + "saving": "Wird gespeichert...", "delete": "Löschen", "rename": "Umbenennen", "edit": "Bearbeiten", "add": "Hinzufügen", "confirm": "Bestätigen", - "no": "NEIN", + "no": "Nein", "or": "ODER", - "next": "Nächste", - "previous": "Vorherige", + "next": "Weiter", + "previous": "Zurück", "refresh": "Aktualisieren", "language": "Sprache", - "checking": "Überprüfung...", + "checking": "Wird geprüft...", "checkingDatabase": "Datenbankverbindung wird geprüft...", "checkingAuthentication": "Authentifizierung wird geprüft...", "backendReconnected": "Serververbindung wiederhergestellt", - "connectionDegraded": "Serververbindung unterbrochen, Wiederherstellung…", + "connectionDegraded": "Serververbindung unterbrochen, Wiederherstellung läuft…", "reload": "Neu laden", "remove": "Entfernen", "create": "Erstellen", "update": "Aktualisieren", - "copy": "Kopie", + "copy": "Kopieren", "copyFailed": "Fehler beim Kopieren in die Zwischenablage", "maximize": "Maximieren", "restore": "Wiederherstellen", @@ -474,20 +474,20 @@ "required": "Erforderlich" }, "nav": { - "home": "Heim", + "home": "Startseite", "terminal": "Terminal", "docker": "Docker", "tunnels": "Tunnel", "fileManager": "Dateimanager", "serverStats": "Host-Metriken", "hostMetrics": "Host-Metriken", - "admin": "Administrator", - "termixId": "AUSWEIS", + "admin": "Admin", + "termixId": "ID", "userProfile": "Benutzerprofil", - "splitScreen": "Split-Screen-Bildschirm", + "splitScreen": "Geteilter Bildschirm", "confirmClose": "Diese aktive Sitzung beenden?", "close": "Schließen", - "cancel": "Stornieren", + "cancel": "Abbrechen", "sshManager": "SSH-Manager", "cannotSplitTab": "Dieser Tab kann nicht geteilt werden", "hostTabTitle": "{{username}}@{{ip}}:{{port}}", @@ -497,18 +497,18 @@ "noPasswordAvailable": "Kein Passwort verfügbar", "failedToCopyPassword": "Passwort konnte nicht kopiert werden", "refreshTab": "Verbindung aktualisieren", - "renameTab": "Registerkarte umbenennen", + "renameTab": "Tab umbenennen", "openFileManager": "Dateimanager öffnen", - "dashboard": "Armaturenbrett", + "dashboard": "Dashboard", "networkGraph": "Netzwerkdiagramm", "tmuxMonitor": "Tmux-Monitor", "homepage": "Startseite", "quickConnect": "Schnellverbindung", "sshTools": "SSH-Tools", - "history": "Geschichte", + "history": "Verlauf", "sessionLogs": "Sitzungsprotokolle", - "hosts": "Gastgeber", - "snippets": "Ausschnitte", + "hosts": "Hosts", + "snippets": "Snippets", "hostManager": "Host-Manager", "credentials": "Anmeldeinformationen", "connections": "Verbindungen", @@ -518,7 +518,7 @@ "roleUser": "Benutzer" }, "hosts": { - "hosts": "Gastgeber", + "hosts": "Hosts", "noHosts": "Keine SSH-Hosts", "retry": "Wiederholen", "refresh": "Aktualisieren", @@ -531,11 +531,11 @@ "ssh": "SSH", "telnet": "Telnet", "remoteDesktop": "Remote-Desktop", - "port": "Hafen", + "port": "Port", "username": "Benutzername", "folder": "Ordner", "tags": "Tags", - "pin": "Stift", + "pin": "Anheften", "addHost": "Host hinzufügen", "editHost": "Host bearbeiten", "cloneHost": "Host klonen", @@ -549,8 +549,8 @@ "authentication": "Authentifizierung", "password": "Passwort", "key": "Schlüssel", - "credential": "Berechtigung", - "none": "Keiner", + "credential": "Zugangsdaten", + "none": "Keine", "sshPrivateKey": "SSH-Privatschlüssel", "keyType": "Schlüsseltyp", "uploadFile": "Datei hochladen", @@ -574,45 +574,45 @@ "status": "Status", "folderRenamed": "Ordner \"{{oldName}}\" erfolgreich umbenannt in \"{{newName}}\"", "failedToRenameFolder": "Ordner konnte nicht umbenannt werden", - "movedToFolder": "{{count}} Host(s) nach \"{{folder}} verschoben", + "movedToFolder": "{{count}} Host(s) nach \"{{folder}}\" verschoben", "editHostTooltip": "Host bearbeiten", "statusChecks": "Statusprüfungen", "metricsCollection": "Metrikensammlung", "metricsInterval": "Metrikenerfassungsintervall", "metricsIntervalDesc": "Wie oft sollen Serverstatistiken erfasst werden (5 Sekunden - 1 Stunde)?", "behavior": "Verhalten", - "themePreview": "Themenvorschau", - "theme": "Thema", + "themePreview": "Designvorschau", + "theme": "Design", "fontFamily": "Schriftfamilie", "fontSize": "Schriftgröße", "letterSpacing": "Buchstabenabstand", - "lineHeight": "Linienhöhe", - "cursorStyle": "Cursor-Stil", - "cursorBlink": "Cursor blinkt", + "lineHeight": "Zeilenhöhe", + "cursorStyle": "Cursorform", + "cursorBlink": "Blinkender Cursor", "scrollbackBuffer": "Scrollback-Puffer", - "bellStyle": "Glockenstil", + "bellStyle": "Signaltonart", "rightClickSelectsWord": "Rechtsklick wählt Wort aus", - "fastScrollModifier": "Schnellscroll-Modifikator", - "fastScrollSensitivity": "Schnelle Scrollempfindlichkeit", + "fastScrollModifier": "Modifikatortaste für schnelles Scrollen", + "fastScrollSensitivity": "Empfindlichkeit für schnelles Scrollen", "sshAgentForwarding": "SSH-Agent-Weiterleitung", "backspaceMode": "Rücktaste-Modus", - "startupSnippet": "Startup-Ausschnitt", - "selectSnippet": "Ausschnitt auswählen", + "startupSnippet": "Start-Snippet", + "selectSnippet": "Snippet auswählen", "forceKeyboardInteractive": "Tastaturinteraktion erzwingen", - "overrideCredentialUsername": "Anmeldeinformationen überschreiben Benutzername", + "overrideCredentialUsername": "Benutzernamen der Zugangsdaten überschreiben", "overrideCredentialUsernameDesc": "Verwenden Sie den oben angegebenen Benutzernamen anstelle des Benutzernamens der Anmeldeinformationen.", "oidcUsernameHint": "Verwenden Sie $oidc.preferred_username, um Ihren OIDC-Anmeldenamen zu ersetzen.", - "jumpHostChain": "Sprungwirtskette", - "portKnocking": "Hafenklopfen", + "jumpHostChain": "Jump-Host-Kette", + "portKnocking": "Port-Knocking", "addKnock": "Port hinzufügen", "addProxyNode": "Knoten hinzufügen", "proxyNode": "Proxy-Knoten", "proxyType": "Proxy-Typ", "quickActions": "Schnellaktionen", - "sudoPasswordAutoFill": "Sudo-Passwort-Auto-Fill", + "sudoPasswordAutoFill": "sudo-Passwort automatisch ausfüllen", "sudoPassword": "Sudo-Passwort", "keepaliveInterval": "Keepalive-Intervall (ms)", - "moshCommand": "MOSH-Kommando", + "moshCommand": "Mosh-Befehl", "environmentVariables": "Umgebungsvariablen", "addVariable": "Variable hinzufügen", "docker": "Docker", @@ -622,7 +622,7 @@ "failedToConnect": "Verbindung zur Konsole fehlgeschlagen.", "connect": "Verbinden", "disconnect": "Trennen", - "start": "Start", + "start": "Starten", "enableStatusCheck": "Statusprüfung aktivieren", "enableMetrics": "Metriken aktivieren", "bulkUpdateFailed": "Massenaktualisierung fehlgeschlagen", @@ -633,21 +633,21 @@ "virtualNetwork": "Virtuelles Netzwerk", "unencryptedShell": "Unverschlüsselte Shell", "addressIp": "Adresse / IP-Adresse", - "friendlyName": "Freundlicher Name", + "friendlyName": "Anzeigename", "macAddress": "MAC-Adresse", - "wolBroadcastAddress": "WoL-Rundfunkansprache", + "wolBroadcastAddress": "WoL-Broadcast-Adresse", "wolBroadcastAddressDesc": "Optionale, gerichtete Broadcast-Adresse für Docker/geroutete Netzwerke (z. B. 192.168.1.255). Feld leer lassen, um 255.255.255.255 zu verwenden.", - "folderAndAdvanced": "Ordner & Erweitert", + "folderAndAdvanced": "Ordner & erweiterte Einstellungen", "privateNotes": "Private Notizen", "privateNotesPlaceholder": "Details zu diesem Server...", "pinToTop": "Anheften", "pinToTopDesc": "Diesen Host immer ganz oben in der Liste anzeigen", - "portKnockingSequence": "Port-Klopfsequenz", + "portKnockingSequence": "Port-Knocking-Sequenz", "addKnockBtn": "Klopfen hinzufügen", "noPortKnocking": "Es ist kein Port-Knocking konfiguriert.", - "knockPort": "Klopfanschluss", + "knockPort": "Knock-Port", "protocol": "Protokoll", - "delayAfterMs": "Verzögerung nach (ms)", + "delayAfterMs": "Anschließende Verzögerung (ms)", "useSocks5Proxy": "SOCKS5-Proxy verwenden", "useSocks5ProxyDesc": "Die Verbindung wird über einen Proxy-Server geleitet.", "proxyHost": "Proxy-Host", @@ -656,8 +656,8 @@ "proxyPassword": "Proxy-Passwort", "proxySingleMode": "Einzelproxy", "proxyChainMode": "Proxy-Kette", - "you": "Du", - "jumpHostChainLabel": "Sprungwirtskette", + "you": "Sie", + "jumpHostChainLabel": "Jump-Host-Kette", "addJumpBtn": "Sprung hinzufügen", "noJumpHosts": "Es sind keine Jump-Hosts konfiguriert.", "selectAServer": "Wählen Sie einen Server aus...", @@ -665,33 +665,33 @@ "authMethod": "Authentifizierungsmethode", "storedCredential": "Gespeicherte Anmeldeinformationen", "selectACredential": "Wählen Sie eine Anmeldeinformation aus...", - "vaultProfile": "Vault Signer Profil", + "vaultProfile": "Vault-Signaturprofil", "selectAVaultProfile": "Wählen Sie ein Vault-Profil aus...", "vaultProfileHint": "Die Einstellungen stammen aus dem freigegebenen Profil; Sie melden sich beim Verbinden über OIDC bei Vault an. Es werden keine Geheimnisse gespeichert.", "vaultNewProfile": "Neues Profil", "vaultManageProfiles": "Vault-Profile verwalten", - "vaultAddrLabel": "Tresoradresse", + "vaultAddrLabel": "Vault-Adresse", "vaultNamespaceLabel": "Namensraum", "vaultOidcMountLabel": "OIDC-Authentifizierungs-Mount", "vaultOidcRoleLabel": "OIDC-Rolle", - "vaultSshMountLabel": "SSH Secrets Mount", + "vaultSshMountLabel": "SSH-Secrets-Mount", "vaultSshRoleLabel": "SSH-Signiererrolle", - "vaultValidPrincipalsLabel": "Gültige Auftraggeber", + "vaultValidPrincipalsLabel": "Gültige Prinzipale", "vaultKeyTypeLabel": "Ephemerer Schlüsseltyp", "vaultSharedLabel": "Mit allen Nutzern teilen", "vaultCreateProfile": "Profil erstellen", - "vaultProfileCreated": "Tresorprofil erstellt", - "vaultProfileSaved": "Tresorprofil gespeichert", + "vaultProfileCreated": "Vault-Profil erstellt", + "vaultProfileSaved": "Vault-Profil gespeichert", "vaultProfileDeleted": "Vault-Profil gelöscht", "vaultProfileSaveFailed": "Das Speichern des Vault-Profils ist fehlgeschlagen.", "vaultProfileDeleteFailed": "Das Löschen des Vault-Profils ist fehlgeschlagen.", "vaultSaveProfile": "Profil speichern", - "vaultProfileValidationError": "Name, Vault-Adresse und SSH-Signaturrolle sind erforderlich.", + "vaultProfileValidationError": "Name, Vault-Adresse und SSH-Signaturrolle sind erforderlich", "vaultNoProfiles": "Noch keine Vault-Profile.", - "vaultSharedBadge": "geteilt", + "vaultSharedBadge": "freigegeben", "keyTypeLabel": "Schlüsseltyp", "keyTypeAuto": "Automatische Erkennung", - "keyPasteTab": "Paste", + "keyPasteTab": "Einfügen", "keyUploadTab": "Hochladen", "keyFileLoaded": "Schlüsseldatei geladen", "keyUploadClick": "Klicken Sie hier, um .pem-, .key- oder .ppk-Dateien hochzuladen.", @@ -707,7 +707,7 @@ "warpgateDesc": "Dieser Host verbindet sich über einen Warpgate-SSH-Proxy. Termix übernimmt nach der Authentifizierung automatisch den browserbasierten Genehmigungsprozess.", "agentLabel": "SSH-Agent", "agentDesc": "Authentifizieren Sie sich mithilfe eines SSH-Agenten, der auf dem Termix-Server ausgeführt wird (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Der Agent muss auf dem Rechner laufen, auf dem Termix gehostet wird.", - "agentSocketPathLabel": "Agent Socket Pfad", + "agentSocketPathLabel": "Pfad zum SSH-Agent-Socket", "agentSocketPathPlaceholder": "Lassen Sie dieses Feld leer, um SSH_AUTH_SOCK zu verwenden.", "agentSocketPathHint": "Lassen Sie das Feld leer, um die automatische Erkennung anhand der Umgebungsvariablen SSH_AUTH_SOCK zu ermöglichen, oder geben Sie einen benutzerdefinierten Socket-Pfad ein (z. B. /run/user/1000/gnupg/S.gpg-agent.ssh).", "tailscaleDeviceSelect": "Tailscale-Gerät auswählen", @@ -715,30 +715,30 @@ "tailscaleNoApiKey": "Es ist kein Tailscale-API-Schlüssel konfiguriert. Fügen Sie einen in den Administratoreinstellungen hinzu, um die Geräteerkennung zu aktivieren.", "tailscaleDocsLink": "Dokumente ansehen", "tailscaleLoadingDevices": "Geräte werden geladen...", - "tailscaleNoDevices": "In Ihrem Endnetz wurden keine Geräte gefunden.", + "tailscaleNoDevices": "In Ihrem Tailnet wurden keine Geräte gefunden.", "tailscaleDeviceAutoFill": "Wenn Sie ein Gerät auswählen, wird die Host-IP-Adresse automatisch ausgefüllt.", "forceKeyboardInteractiveLabel": "Tastaturinteraktion erzwingen", - "forceKeyboardInteractiveShortDesc": "Erzwinge die manuelle Passworteingabe, auch wenn Schlüssel vorhanden sind.", - "allowLegacyAlgorithmsLabel": "Legacy-Algorithmen zulassen", + "forceKeyboardInteractiveShortDesc": "Manuelle Passworteingabe erzwingen, auch wenn Schlüssel vorhanden sind", + "allowLegacyAlgorithmsLabel": "Veraltete Algorithmen zulassen", "allowLegacyAlgorithmsDesc": "Aktivieren Sie veraltete SSH-Algorithmen (ssh-dss, ssh-rsa, diffie-hellman-group1-sha1, hmac-md5, 3des-cbc) für Verbindungen zu alten Geräten, die nicht aktualisiert werden können.", "insecure": "Unsicher", "terminalAppearance": "Terminalaussehen", "colorTheme": "Farbthema", "fontFamilyLabel": "Schriftfamilie", "fontSizeLabel": "Schriftgröße", - "cursorStyleLabel": "Cursor-Stil", + "cursorStyleLabel": "Cursorform", "letterSpacingPx": "Buchstabenabstand (px)", - "lineHeightLabel": "Linienhöhe", - "bellStyleLabel": "Glockenstil", + "lineHeightLabel": "Zeilenhöhe", + "bellStyleLabel": "Signaltonart", "backspaceModeLabel": "Rücktaste-Modus", - "cursorBlinking": "Cursor blinkt", + "cursorBlinking": "Blinkender Cursor", "cursorBlinkingDesc": "Blinkanimation für den Terminalcursor aktivieren", "rightClickSelectsWordLabel": "Rechtsklick wählt Wort aus", "rightClickSelectsWordShortDesc": "Wählen Sie das Wort unter dem Cursor per Rechtsklick aus.", "backgroundImageLabel": "URL des Hintergrundbildes", "backgroundImageDesc": "Optionale URL für ein Terminal-Hintergrundbild", "backgroundImageOpacityLabel": "Hintergrundbilddeckkraft", - "customThemeColors": "Sonderfarben", + "customThemeColors": "Benutzerdefinierte Farben", "customThemeBackground": "Hintergrund", "customThemeForeground": "Vordergrund", "customThemeCursor": "Cursor", @@ -753,64 +753,64 @@ "customThemeMagenta": "Magenta", "customThemeCyan": "Cyan", "customThemeWhite": "Weiß", - "customThemeBrightBlack": "Hochschwarz", - "customThemeBrightRed": "Leuchtend Rot", + "customThemeBrightBlack": "Hellschwarz", + "customThemeBrightRed": "Hellrot", "customThemeBrightGreen": "Hellgrün", - "customThemeBrightYellow": "Leuchtend Gelb", + "customThemeBrightYellow": "Hellgelb", "customThemeBrightBlue": "Hellblau", "customThemeBrightMagenta": "Helles Magenta", "customThemeBrightCyan": "Helles Cyan", - "customThemeBrightWhite": "Strahlend Weiß", + "customThemeBrightWhite": "Hellweiß", "customThemeResetTooltip": "Auf Standardeinstellungen zurücksetzen", "syntaxHighlightingLabel": "Syntaxhervorhebung", "syntaxHighlightingDesc": "Terminalausgabe farbig darstellen (Fehler, Pfade, IPs, Zeitstempel)", "syntaxHighlightingCategories": "Hervorhebungskategorien", "syntaxHighlightingCategoriesDesc": "Wählen Sie aus, welche Inhaltstypen farblich hervorgehoben werden sollen.", "syntaxCategoryLogLevels": "Protokollierungsstufen", - "syntaxCategoryLogLevelsDesc": "Fehler, Warnung, Info, Debug, schwerwiegend", + "syntaxCategoryLogLevelsDesc": "error, warn, info, debug, fatal", "syntaxCategoryPaths": "Dateipfade", "syntaxCategoryPathsDesc": "/usr/share/doc, ~/file.txt", "syntaxCategoryTimestamps": "Zeitstempel", - "syntaxCategoryTimestampsDesc": "[12:34:56], 15.01.2024", + "syntaxCategoryTimestampsDesc": "[12:34:56], 2024-01-15", "syntaxCategoryIpAddresses": "IP-Adressen", "syntaxCategoryIpAddressesDesc": "192.168.1.1, 10.0.0.1:8080", "syntaxCategoryUrls": "URLs", "syntaxCategoryUrlsDesc": "https://example.com", "syntaxCategoryNumbers": "Beschriftete Zahlen", - "syntaxCategoryNumbersDesc": "Port 8080, Ausgang 1, Status 404", - "behaviorAndAdvanced": "Verhalten & Fortgeschrittene", + "syntaxCategoryNumbersDesc": "port 8080, exit 1, status 404", + "behaviorAndAdvanced": "Verhalten & erweiterte Einstellungen", "scrollbackBufferLabel": "Scrollback-Puffer", "scrollbackMaxLines": "Maximale Anzahl der im Verlauf gespeicherten Zeilen", "sshAgentForwardingLabel": "SSH-Agent-Weiterleitung", - "sshAgentForwardingShortDesc": "Übergeben Sie Ihre lokalen SSH-Schlüssel an diesen Host.", + "sshAgentForwardingShortDesc": "Ihre lokalen SSH-Schlüssel an diesen Host weiterleiten", "useSSHTitleLabel": "SSH-Fenstertitel verwenden", "useSSHTitleDesc": "Aktualisieren Sie den Tab-Titel anhand des Fenstertitels der Shell anstelle des Hostnamens.", "enableAutoMosh": "Auto-Mosh aktivieren", - "enableAutoMoshDesc": "Bevorzugen Sie Mosh gegenüber SSH, falls verfügbar.", + "enableAutoMoshDesc": "Mosh bevorzugen, falls verfügbar", "enableAutoTmux": "Auto-Tmux aktivieren", - "enableAutoTmuxDesc": "Automatisches Starten oder Anhängen an eine tmux-Sitzung", + "enableAutoTmuxDesc": "Automatisch eine tmux-Sitzung starten oder an eine bestehende anhängen", "enableSessionLogging": "Sitzungsprotokollierung", "enableSessionLoggingDesc": "Terminalausgabe zur späteren Überprüfung aufzeichnen", "enableCommandHistory": "Befehlshistorie", - "enableCommandHistoryDesc": "Die in diesem Terminal ausgeführten Befehle werden zur Protokollierung und für die Autovervollständigung protokolliert.", + "enableCommandHistoryDesc": "In diesem Terminal ausgeführte Befehle für den Verlauf und die Autovervollständigung aufzeichnen", "linkClickBehaviorLabel": "Link-Klickverhalten", "linkClickBehaviorDesc": "Steuert das Verhalten beim Klicken auf einen Link im Terminal. Verwenden Sie „Standard“, um die Anwendungseinstellung zu übernehmen.", "linkClickBehaviorDefault": "Standardeinstellung (entsprechend den App-Einstellungen)", "linkClickBehaviorConfirm": "Popup zum Öffnen oder Kopieren anzeigen", "linkClickBehaviorDirect": "Direkt öffnen", - "sudoPasswordAutoFillLabel": "Sudo-Passwort-Auto-Ausfüllen", - "sudoPasswordAutoFillShortDesc": "Das sudo-Passwort wird bei Aufforderung automatisch bereitgestellt.", - "sudoPasswordAutoFillDesc": "Speichern Sie ein sudo-Passwort, damit Host-Metriken, Terminal-Eingabeaufforderungen und andere automatisch privilegierte Befehle ausführen können.", - "sudoPasswordLabel": "Sudo-Passwort", + "sudoPasswordAutoFillLabel": "sudo-Passwort automatisch ausfüllen", + "sudoPasswordAutoFillShortDesc": "sudo-Passwort bei Aufforderung automatisch eingeben", + "sudoPasswordAutoFillDesc": "Speichern Sie ein sudo-Passwort, damit Host-Metriken, Terminal-Eingabeaufforderungen und andere Funktionen privilegierte Befehle automatisch ausführen können.", + "sudoPasswordLabel": "sudo-Passwort", "environmentVariablesLabel": "Umgebungsvariablen", "addVariableBtn": "Variable hinzufügen", "noEnvVars": "Es sind keine Umgebungsvariablen konfiguriert.", - "fastScrollModifierLabel": "Schnellscroll-Modifikator", - "fastScrollSensitivityLabel": "Schnelle Scrollempfindlichkeit", - "moshCommandLabel": "Mosh Command", - "startupSnippetLabel": "Startup-Ausschnitt", + "fastScrollModifierLabel": "Modifikatortaste für schnelles Scrollen", + "fastScrollSensitivityLabel": "Empfindlichkeit für schnelles Scrollen", + "moshCommandLabel": "Mosh-Befehl", + "startupSnippetLabel": "Start-Snippet", "keepaliveIntervalLabel": "Keepalive-Intervall (Sekunden)", - "maxKeepaliveMisses": "Max Keepalive-Fehlschläge", + "maxKeepaliveMisses": "Max. Anzahl verpasster Keepalives", "tunnelSettings": "Tunneleinstellungen", "enableTunneling": "Tunneling aktivieren", "enableTunnelingDesc": "Aktivieren Sie die SSH-Tunnelfunktionalität für diesen Host.", @@ -819,23 +819,23 @@ "noTunnelsConfigured": "Es sind keine Tunnel konfiguriert.", "tunnelLabel": "Tunnel {{number}}", "tunnelType": "Tunneltyp", - "tunnelModeLocalDesc": "Leite einen lokalen Port an einen Port auf dem Remote-Server (oder einem von dort aus erreichbaren Host) weiter.", + "tunnelModeLocalDesc": "Leiten Sie einen lokalen Port an einen Port auf dem Remote-Server (oder an einen von dort erreichbaren Host) weiter.", "tunnelModeRemoteDesc": "Leiten Sie einen Port auf dem Remote-Server an einen lokalen Port auf Ihrem Rechner weiter.", "tunnelModeDynamicDesc": "Erstellen Sie einen SOCKS5-Proxy auf einem lokalen Port für die dynamische Portweiterleitung.", "sameHost": "Dieser Host (direkter Tunnel)", - "endpointHost": "Endpunkthost", - "endpointHostPlaceholder": "Derselbe Host, SSH-Host oder erreichbarer Host/IP-Adresse", + "endpointHost": "Endpunkt-Host", + "endpointHostPlaceholder": "Derselbe Host, ein SSH-Host oder ein erreichbarer Host bzw. eine erreichbare IP-Adresse", "endpointPort": "Endpunkt-Port", - "bindHost": "Bind Host", + "bindHost": "Bind-Adresse", "sourcePort": "Quellport", "maxRetries": "Maximale Wiederholungsversuche", "retryIntervalS": "Wiederholungsintervall (s)", "autoStartLabel": "Automatischer Start", "autoStartDesc": "Dieser Tunnel wird automatisch verbunden, sobald der Host geladen ist.", - "tunnelConnecting": "Tunnelverbindung...", - "tunnelDisconnected": "Tunnel unterbrochen", + "tunnelConnecting": "Tunnel wird verbunden...", + "tunnelDisconnected": "Tunnel getrennt", "failedToConnectTunnel": "Verbindung fehlgeschlagen", - "failedToDisconnectTunnel": "Verbindung konnte nicht hergestellt werden", + "failedToDisconnectTunnel": "Trennen fehlgeschlagen", "dockerIntegration": "Docker-Integration", "enableDockerMonitor": "Docker aktivieren", "enableDockerMonitorDesc": "Überwachen und verwalten Sie Container auf diesem Host über Docker.", @@ -844,44 +844,59 @@ "containerRuntimeDocker": "Docker", "containerRuntimePodman": "Podman", "enableTmuxMonitor": "Tmux-Monitor aktivieren", - "enableTmuxMonitorDesc": "Zeigen Sie diesen Host im Tmux-Monitor an und fügen Sie seine Tmux-Aktionen zur Seitenleiste hinzu.", + "enableTmuxMonitorDesc": "Diesen Host im Tmux-Monitor anzeigen und seine tmux-Aktionen zur Seitenleiste hinzufügen", "tabProxmox": "Proxmox", "proxmoxIntegration": "Proxmox-Integration", "enableProxmox": "Proxmox aktivieren", "enableProxmoxDesc": "Markieren Sie diesen Host als Proxmox-Knoten. Dadurch wird die Gasterkennung und der Import direkt von diesem Host ermöglicht.", "proxmoxDefaultAuthType": "Standard-Authentifizierungstyp", - "proxmoxDefaultAuthTypeDesc": "Die Authentifizierungsmethode wurde für importierte Gasthosts angewendet. Wählen Sie den Authentifizierungstyp, der Ihrer Verbindungsart zu Ihren Gästen entspricht.", + "proxmoxDefaultAuthTypeDesc": "Authentifizierungsmethode für importierte Gast-Hosts. Wählen Sie den Authentifizierungstyp, mit dem Sie sich bei Ihren Gastsystemen verbinden.", "authTypePassword": "Passwort", "authTypeKey": "SSH-Schlüssel", - "authTypeCredential": "Berechtigung", + "authTypeCredential": "Anmeldeinformationen", "authTypeOpkssh": "OPKSSH", - "authTypeNone": "Keiner", + "authTypeNone": "Keine", "proxmoxDefaultCredential": "Standardanmeldeinformationen", "proxmoxDefaultCredentialDesc": "Anmeldeinformationen, die für importierte Gasthosts verwendet werden, wenn der Authentifizierungstyp auf „Anmeldeinformationen“ eingestellt ist.", "proxmoxWindowsDetection": "Windows-/RDP-Erkennung", "proxmoxWindowsDetectionDesc": "Durch Kommas getrennte Namensmuster, die RDP anstelle von SSH auslösen (Groß-/Kleinschreibung wird nicht beachtet)", "proxmoxDockerDetection": "Docker-Erkennung", - "proxmoxDockerDetectionDesc": "Kommagetrennte Namensmuster, die Docker die Zuordnung von Gästen ermöglichen.", + "proxmoxDockerDetectionDesc": "Durch Kommas getrennte Namensmuster, die Docker für passende Gäste aktivieren", "proxmoxPreferredRanges": "Bevorzugte IP-Bereiche", "proxmoxPreferredRangesDesc": "Durch Kommas getrennte Präfixe in Prioritätsreihenfolge für die IP-Auswahl, wenn ein Gast über mehrere Schnittstellen verfügt.", + "proxmoxAutoSync": "Gäste automatisch synchronisieren", + "proxmoxAutoSyncDesc": "Ermitteln Sie diesen Proxmox-Knoten regelmäßig und erstellen oder aktualisieren Sie importierte Gasthosts, während Ihre Sitzung entsperrt ist.", + "proxmoxSyncInterval": "Synchronisierungsintervall (Minuten)", + "proxmoxSyncIntervalDesc": "Mindestens 5 Minuten. Der Scheduler überspringt gesperrte Benutzersitzungen.", + "proxmoxMarkMissing": "Mark vermisste Gäste", + "proxmoxMarkMissingDesc": "Füge ein proxmox-missing-Tag hinzu, wenn ein zuvor importierter Gast verschwindet, anstatt ihn zu löschen.", + "proxmoxLastSync": "Letzte Synchronisierung", + "proxmoxLastSyncNever": "Noch nicht synchronisiert", + "proxmoxLastSyncNoResult": "Keine Ergebnisdetails", + "proxmoxLastSyncSummary": "{{created}} erstellt, {{updated}} aktualisiert, {{markedMissing}} fehlt, {{skipped}} übersprungen", + "proxmoxLastSyncStatus": { + "success": "Erfolg", + "error": "Fehlgeschlagen", + "pending": "Ausstehend" + }, "proxmoxDiscoverAction": "Proxmox-Gäste entdecken und importieren", "proxmoxImportTitle": "Import von Proxmox", "proxmoxSelectHost": "Wählen Sie einen Proxmox-Host aus…", - "proxmoxDiscover": "Entdecken", - "proxmoxDiscovering": "Entdecke…", - "proxmoxDiscoverGuests": "Entdecken Sie Gäste", + "proxmoxDiscover": "Ermitteln", + "proxmoxDiscovering": "Wird ermittelt…", + "proxmoxDiscoverGuests": "Gäste ermitteln", "proxmoxGuestsSelected_one": "{{count}} Gast — {{selected}} ausgewählt", "proxmoxGuestsSelected_other": "{{count}} Gäste — {{selected}} ausgewählt", "proxmoxSelectAll": "Alle auswählen", "proxmoxDeselectAll": "Alle abwählen", "proxmoxNoGuests": "Auf diesem Proxmox-Knoten wurden keine Gäste gefunden.", - "proxmoxImportButton_one": "Importieren Sie {{count}} Host", - "proxmoxImportButton_other": "Importieren Sie {{count}} Hosts", + "proxmoxImportButton_one": "{{count}} Host importieren", + "proxmoxImportButton_other": "{{count}} Hosts importieren", "proxmoxResultImported": "{{count}} importiert", "proxmoxResultUpdated": "{{count}} aktualisiert", "proxmoxResultFailed": "{{count}} fehlgeschlagen", "proxmoxImportComplete": "Proxmox-Import abgeschlossen: {{summary}}", - "proxmoxDiscoveryFailed": "Entdeckung fehlgeschlagen", + "proxmoxDiscoveryFailed": "Ermittlung fehlgeschlagen", "proxmoxImportFailed": "Import fehlgeschlagen", "enableFileManagerMonitor": "Dateimanager aktivieren", "enableFileManagerMonitorDesc": "Dateien auf diesem Host über SFTP durchsuchen und verwalten", @@ -891,26 +906,26 @@ "fileManagerPathHint": "Das Verzeichnis, das beim Start des Dateimanagers für diesen Host geöffnet werden soll.", "statusChecksLabel": "Statusprüfungen", "enableStatusChecks": "Statusprüfungen aktivieren", - "enableStatusChecksDesc": "Prüfen Sie die Verfügbarkeit, indem Sie diesen Host regelmäßig anpingen.", + "enableStatusChecksDesc": "Verfügbarkeit dieses Hosts regelmäßig per Ping prüfen", "useGlobalInterval": "Globales Intervall verwenden", - "useGlobalIntervalDesc": "Überschreiben Sie dies mit dem serverweiten Statusprüfungsintervall.", + "useGlobalIntervalDesc": "Stattdessen das serverweite Statusprüfungsintervall verwenden", "checkIntervalS": "Prüfintervall (s)", "checkIntervalDesc": "Sekunden zwischen jedem Verbindungsping", "metricsCollectionLabel": "Metrikensammlung", "enableMetricsLabel": "Metriken aktivieren", "enableMetricsDesc": "Erfassen Sie CPU-, RAM-, Festplatten- und andere Metriken von diesem Host", "useGlobalMetrics": "Globales Intervall verwenden", - "useGlobalMetricsDesc": "Überschreiben mit dem serverweiten Metrikintervall", + "useGlobalMetricsDesc": "Serverweites Metrikintervall verwenden", "metricsIntervalS": "Metrikintervall (s)", "metricsIntervalDesc2": "Sekunden zwischen Metrik-Snapshots", "visibleWidgets": "Sichtbare Widgets", "widgetsMovedToHostMetrics": "Karten können jetzt direkt im Tab „Hostmetriken“ hinzugefügt, angeordnet und in der Größe angepasst werden. Öffnen Sie die Hostmetriken für diesen Host und wählen Sie über „Anpassen“ aus, welche Karten angezeigt werden sollen.", "cpuUsageLabel": "CPU-Auslastung", "cpuUsageDesc": "CPU-Auslastung, Lastdurchschnitte, Sparkline-Diagramm", - "memoryLabel": "Speichernutzung", + "memoryLabel": "Arbeitsspeicherauslastung", "memoryDesc": "RAM-Nutzung, Auslagerungsspeicher, Cache", "storageLabel": "Festplattennutzung", - "storageDesc": "Festplattennutzung pro Mountpunkt", + "storageDesc": "Festplattennutzung pro Einhängepunkt", "networkLabel": "Netzwerkschnittstellen", "networkDesc": "Schnittstellenliste und Bandbreite", "uptimeLabel": "Betriebszeit", @@ -921,21 +936,21 @@ "recentLoginsDesc": "Erfolgreiche und fehlgeschlagene Anmeldeereignisse", "topProcessesLabel": "Top-Prozesse", "topProcessesDesc": "PID, CPU%, MEM%, Befehl", - "listeningPortsLabel": "Abhöranschlüsse", + "listeningPortsLabel": "Lauschende Ports", "listeningPortsDesc": "Offene Ports mit Prozess und Status", "firewallLabel": "Firewall", "firewallDesc": "Firewall-, AppArmor- und SELinux-Status", "quickActionsLabel": "Schnellaktionen", - "quickActionsToolbar": "Schnellaktionen werden als Schaltflächen in der Host-Metrik-Symbolleiste angezeigt, um Befehle mit einem Klick auszuführen.", - "noQuickActions": "Noch keine Schnellmaßnahmen.", + "quickActionsToolbar": "Schnellaktionen werden als Schaltflächen in der Symbolleiste der Host-Metriken angezeigt, um Befehle mit einem Klick auszuführen.", + "noQuickActions": "Noch keine Schnellaktionen.", "buttonLabel": "Schaltflächenbeschriftung", - "selectSnippetPlaceholder": "Ausschnitt auswählen...", + "selectSnippetPlaceholder": "Snippet auswählen...", "addActionBtn": "Aktion hinzufügen", "hostSharedSuccessfully": "Host erfolgreich freigegeben", - "failedToShareHost": "Host konnte nicht geteilt werden", + "failedToShareHost": "Host konnte nicht freigegeben werden", "accessRevoked": "Zugriff widerrufen", - "failedToRevokeAccess": "Zugriff widerrufen fehlgeschlagen", - "cancelBtn": "Stornieren", + "failedToRevokeAccess": "Zugriff konnte nicht widerrufen werden", + "cancelBtn": "Abbrechen", "savingBtn": "Speichern...", "addHostBtn": "Host hinzufügen", "hostUpdated": "Host aktualisiert", @@ -946,35 +961,35 @@ "failedToSaveCredential": "Anmeldeinformationen konnten nicht gespeichert werden", "credentialNameRequired": "Bitte geben Sie einen Namen für die Zugangsdaten ein.", "credentialAuthRequired": "Fügen Sie ein Passwort, einen SSH-Schlüssel oder beides hinzu.", - "backToHosts": "Zurück zu den Gastgebern", + "backToHosts": "Zurück zu den Hosts", "backToCredentials": "Zurück zu den Anmeldeinformationen", "pinned": "Angepinnt", "noHostsFound": "Keine Hosts gefunden", "tryDifferentTerm": "Versuchen Sie einen anderen Begriff", - "addFirstHost": "Fügen Sie Ihren ersten Host hinzu, um loszulegen.", + "addFirstHost": "Fügen Sie zunächst Ihren ersten Host hinzu.", "noCredentialsFound": "Es wurden keine Anmeldeinformationen gefunden.", "addCredentialBtn": "Anmeldeinformationen hinzufügen", "updateCredentialBtn": "Anmeldeinformationen aktualisieren", - "features": "Merkmale", + "features": "Funktionen", "noFolder": "(Kein Ordner)", "deleteSelected": "Löschen", - "exitSelection": "Ausstiegsauswahl", + "exitSelection": "Auswahl beenden", "importSkip": "Importieren (vorhandene überspringen)", "importOverwrite": "Importieren (überschreiben)", - "collapseBtn": "Zusammenbruch", + "collapseBtn": "Einklappen", "importExportBtn": "Import / Export", "hostStatusesRefreshed": "Hoststatus aktualisiert", "failedToRefreshHosts": "Fehler beim Aktualisieren der Hosts", - "movedHostTo": "Verschoben {{host}} nach \"{{folder}}\"", + "movedHostTo": "{{host}} nach \"{{folder}}\" verschoben", "failedToMoveHost": "Fehler beim Verschieben des Hosts", - "folderRenamedTo": "Ordner umbenannt in \"{{name}}\"", - "deletedFolder": "Gelöschter Ordner \"{{name}}\"", + "folderRenamedTo": "Ordner in \"{{name}}\" umbenannt", + "deletedFolder": "Ordner \"{{name}}\" gelöscht", "failedToDeleteFolder": "Ordner konnte nicht gelöscht werden", "deleteAllInFolder": "Alle Hosts in \"{{name}}\" löschen? Dies kann nicht rückgängig gemacht werden.", "folderPickerPlaceholder": "Kein Ordner", "folderPickerSearch": "Suchen oder erstellen (verwenden Sie / für Unterordner)...", "folderPickerNone": "Kein Ordner", - "folderPickerCreate": "Erstelle \"{{path}}\"", + "folderPickerCreate": "\"{{path}}\" erstellen", "folderPickerEmpty": "Keine passenden Ordner", "newFolder": "Neuer Ordner", "createFolderTitle": "Ordner erstellen", @@ -989,27 +1004,27 @@ "folderNameFallback": "Unbenannter Ordner", "createFolderButton": "Ordner erstellen", "saveFolderButton": "Ordner speichern", - "cancel": "Stornieren", - "iconSearchPlaceholder": "Suchsymbole...", + "cancel": "Abbrechen", + "iconSearchPlaceholder": "Symbole suchen...", "openAllSessions": "Alle Sitzungen öffnen", "editFolder": "Ordner bearbeiten", "deleteFolder": "Ordner löschen", "folderSaved": "Ordner gespeichert", "failedToSaveFolder": "Ordner konnte nicht gespeichert werden", - "folderDeleted": "Gelöschter Ordner \"{{name}}\"", - "deleteFolderConfirm": "Soll das \"{{name}}\" und seine {{count}} Hosts gelöscht werden? Dies kann nicht rückgängig gemacht werden.", + "folderDeleted": "Ordner \"{{name}}\" gelöscht", + "deleteFolderConfirm": "\"{{name}}\" und die darin enthaltenen {{count}} Host(s) löschen? Dies kann nicht rückgängig gemacht werden.", "failedToMoveHosts": "Fehler beim Verschieben der Hosts", "expandAll": "Alle Ordner erweitern", - "collapseAll": "Alle Ordner ausblenden", + "collapseAll": "Alle Ordner einklappen", "moreActions": "Mehr", "groupBy": "Gruppieren nach", "GroupByFolder": "Ordner", - "GroupByTag": "Etikett", + "GroupByTag": "Tag", "GroupByStatus": "Status", "GroupByProtocol": "Protokoll", "GroupByAuth": "Authentifizierungstyp", "groupUngrouped": "Nicht gruppiert", - "deletedHost": "Gelöscht {{name}}", + "deletedHost": "{{name}} gelöscht", "copiedToClipboard": "In die Zwischenablage kopiert", "terminalUrlCopied": "Terminal-URL kopiert", "fileManagerUrlCopied": "Dateimanager-URL kopiert", @@ -1022,12 +1037,12 @@ "telnetUrlCopied": "Telnet-URL kopiert", "remoteDesktopUrlCopied": "Remote-Desktop-URL kopiert", "expandActions": "Aktionen erweitern", - "collapseActions": "Aktionen zusammenbrechen", + "collapseActions": "Aktionen einklappen", "wakeOnLanAction": "Wake on LAN", "wakeOnLanSuccess": "Magisches Paket an {{name}} gesendet", "wakeOnLanError": "Das Senden des Magic Packets ist fehlgeschlagen.", "cloneHostAction": "Host klonen", - "copyAddress": "Kopie Adresse", + "copyAddress": "Adresse kopieren", "copyLink": "Link kopieren", "copyTerminalUrlAction": "Terminal-URL kopieren", "copyFileManagerUrlAction": "URL des Dateimanagers kopieren", @@ -1039,13 +1054,13 @@ "copyVncUrlAction": "VNC-URL kopieren", "copyTelnetUrlAction": "Telnet-URL kopieren", "copyRemoteDesktopUrlAction": "Remote-Desktop-URL kopieren", - "deleteCredentialConfirm": "Anmeldeinformationen löschen \"{{name}}\"?", + "deleteCredentialConfirm": "Zugangsdaten „{{name}}“ löschen?", "deletedCredential": "Gelöscht {{name}}", "deploySSHKeyTitle": "SSH-Schlüssel bereitstellen", "deployingBtn": "Wird bereitgestellt...", - "deployBtn": "Einsetzen", + "deployBtn": "Bereitstellen", "failedToDeployKey": "Fehler beim Bereitstellen des Schlüssels", - "deleteHostsConfirm": "Löschen {{count}} Host{{plural}}? Dies kann nicht rückgängig gemacht werden.", + "deleteHostsConfirm": "{{count}} Host{{plural}} löschen? Dies kann nicht rückgängig gemacht werden.", "movedToRoot": "Nach Stammverzeichnis verschoben", "enableTerminalFeature": "Terminal aktivieren", "disableTerminalFeature": "Terminal deaktivieren", @@ -1062,15 +1077,15 @@ "credType": "Typ", "generateKeyPairDesc": "Es wird ein neues Schlüsselpaar generiert, wobei sowohl der private als auch der öffentliche Schlüssel automatisch ausgefüllt werden.", "generatingKey": "Wird generiert...", - "generateLabel": "Generieren {{label}}", + "generateLabel": "{{label}} generieren", "uploadFileBtn": "Datei hochladen", "keyPassphraseOptional": "Schlüsselpasswort (optional)", "sshPublicKeyOptional": "Öffentlicher SSH-Schlüssel (optional)", "publicKeyGenerated": "Öffentlicher Schlüssel generiert", - "failedToGeneratePublicKey": "Öffentlichen Schlüssel konnte nicht abgeleitet werden", + "failedToGeneratePublicKey": "Öffentlicher Schlüssel konnte nicht abgeleitet werden", "publicKeyCopied": "Öffentlicher Schlüssel kopiert", - "keyPairGenerated": "{{label}} Schlüsselpaar generiert", - "failedToGenerateKeyPair": "Fehler beim Generieren des Schlüsselpaares", + "keyPairGenerated": "{{label}}-Schlüsselpaar generiert", + "failedToGenerateKeyPair": "Schlüsselpaar konnte nicht generiert werden", "searchHostsPlaceholder": "Hosts, Adressen und Tags durchsuchen…", "searchCredentialsPlaceholder": "Anmeldeinformationen suchen…", "refreshBtn": "Aktualisieren", @@ -1085,7 +1100,7 @@ "basicInformation": "Grundlegende Informationen", "authDetailsSection": "Authentifizierungsdetails", "credTypeLabel": "Typ", - "hostsTab": "Gastgeber", + "hostsTab": "Hosts", "credentialsTab": "Anmeldeinformationen", "selectMultiple": "Mehrere auswählen", "selectHosts": "Hosts auswählen", @@ -1095,35 +1110,35 @@ "generateKeyPairDescription": "Es wird ein neues Schlüsselpaar generiert, wobei sowohl der private als auch der öffentliche Schlüssel automatisch ausgefüllt werden.", "generateFromPrivateKey": "Aus privatem Schlüssel generieren", "refreshBtn2": "Aktualisieren", - "exitSelectionTitle": "Ausstiegsauswahl", + "exitSelectionTitle": "Auswahl beenden", "exportAll": "Alle exportieren", - "exportForSharing": "Exportieren zum Teilen", + "exportForSharing": "Für die Freigabe exportieren", "addHostBtn2": "Host hinzufügen", "addCredentialBtn2": "Anmeldeinformationen hinzufügen", "checkingHostStatuses": "Hoststatus werden überprüft...", "pinnedSection": "Angepinnt", "hostsExported": "Hosts erfolgreich exportiert", - "hostsShareExported": "Gemeinsam nutzbare Hosts erfolgreich exportiert", - "exportFailed": "Fehler beim Exportieren der Hosts", + "hostsShareExported": "Freigebbare Hosts erfolgreich exportiert", + "exportFailed": "Hosts konnten nicht exportiert werden", "sampleDownloaded": "Beispieldatei heruntergeladen", "failedToDeleteCredential2": "Anmeldeinformationen konnten nicht gelöscht werden", "noFolderOption": "(Kein Ordner)", "nSelected": "{{count}} ausgewählt", - "featuresMenu": "Merkmale", - "moveMenu": "Bewegen", + "featuresMenu": "Funktionen", + "moveMenu": "Verschieben", "connectSelected": "Verbinden", - "cancelSelection": "Stornieren", - "deployDialogDesc": "Deploy {{name}} to a host authorized_keys.", + "cancelSelection": "Abbrechen", + "deployDialogDesc": "{{name}} in der Datei authorized_keys eines Hosts bereitstellen.", "targetHostLabel": "Zielhost", "selectHostOption": "Wählen Sie einen Host aus...", "keyDeployedSuccess": "Schlüssel erfolgreich bereitgestellt", "failedToDeployKey2": "Fehler beim Bereitstellen des Schlüssels", - "deletedCount": "Gelöschte Hosts {{count}}", - "failedToDeleteCount": "Fehler beim Löschen der Hosts {{count}}", - "duplicatedHost": "Dupliziert \"{{name}}\"", + "deletedCount": "{{count}} Hosts gelöscht", + "failedToDeleteCount": "{{count}} Hosts konnten nicht gelöscht werden", + "duplicatedHost": "\"{{name}}\" dupliziert", "failedToDuplicateHost": "Fehler beim Duplizieren des Hosts", - "updatedCount": "Aktualisierte Hosts {{count}}", - "friendlyNameLabel": "Freundlicher Name", + "updatedCount": "{{count}} Hosts aktualisiert", + "friendlyNameLabel": "Anzeigename", "descriptionLabel": "Beschreibung", "loadingHost": "Host wird geladen...", "loadingHosts": "Hosts werden geladen...", @@ -1137,10 +1152,10 @@ "sortNameAsc": "Name (A → Z)", "sortNameDesc": "Name (Z → A)", "sortIpAsc": "IP-Adresse (aufsteigend)", - "sortIpDesc": "IP-Adresse (Beschreibung)", - "sortOnlineFirst": "Online First", - "sortOfflineFirst": "Offline First", - "sortPinnedFirst": "Als erstes angepinnt", + "sortIpDesc": "IP-Adresse (absteigend)", + "sortOnlineFirst": "Online zuerst", + "sortOfflineFirst": "Offline zuerst", + "sortPinnedFirst": "Angeheftete zuerst", "filterHosts": "Hosts filtern", "filterClearAll": "Filter löschen", "filterStatusGroup": "Status", @@ -1150,15 +1165,15 @@ "filterAuthGroup": "Authentifizierungstyp", "filterAuthPassword": "Passwort", "filterAuthKey": "SSH-Schlüssel", - "filterAuthCredential": "Berechtigung", - "filterAuthNone": "Keiner", + "filterAuthCredential": "Anmeldeinformationen", + "filterAuthNone": "Keine", "filterAuthOpkssh": "OPKSSH", "filterProtocolGroup": "Protokoll", "filterProtocolSsh": "SSH", "filterProtocolRdp": "RDP", "filterProtocolVnc": "VNC", "filterProtocolTelnet": "Telnet", - "filterFeaturesGroup": "Merkmale", + "filterFeaturesGroup": "Funktionen", "filterFeatureTerminal": "Terminal", "filterFeatureFileManager": "Dateimanager", "filterFeatureTunnel": "Tunnel", @@ -1167,7 +1182,57 @@ "shareHost": "Host teilen", "shareHostTitle": "Teilen: {{name}}", "sharing": { - "requiresCredential": "Dieser Host benötigt Anmeldeinformationen, um die Freigabe zu aktivieren. Bearbeiten Sie den Host und weisen Sie ihm zunächst Anmeldeinformationen zu." + "loadError": "Die Freigabedaten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "shareWithSection": "Teilen mit", + "usersTab": "Benutzer", + "rolesTab": "Rollen", + "searchPlaceholder": "Benutzer oder Rollen suchen...", + "noMatches": "Keine Treffer gefunden", + "permissionLevelLabel": "Berechtigungsstufe", + "levels": { + "connect": { + "label": "Verbinden", + "description": "Nur offene Sitzungen: Terminal, Remote-Desktop, Dateimanager, Tunnel und Docker. Kein Zugriff auf die Host-Konfiguration." + }, + "view": { + "label": "Sicht", + "description": "Stellen Sie eine Verbindung her und sehen Sie sich die Host-Konfiguration an. Geheimnisse werden niemals angezeigt." + }, + "edit": { + "label": "Bearbeiten", + "description": "Hostdaten können angezeigt und geändert werden. Geheimnisse können ersetzt, aber niemals gelesen werden; Zugangsdaten bleiben nur dem Besitzer zugänglich." + }, + "manage": { + "label": "Verwalten", + "description": "Bearbeiten und den Host mit anderen teilen, Berechtigungsstufen ändern und den Zugriff widerrufen." + } + }, + "expiryLabel": "Ablauf des Zugriffs", + "expiry": { + "never": "Niemals", + "oneHour": "1 Stunde", + "oneDay": "24 Stunden", + "sevenDays": "7 Tage", + "thirtyDays": "30 Tage", + "custom": "Brauch" + }, + "customHoursPlaceholder": "Zeit bis zum Ablauf des Zugangs", + "shareButton": "Aktie", + "shareWithCount": "Teilen ({{count}})", + "currentAccess": "Aktueller Zugriff", + "noAccessEntries": "Dieser Host wurde noch nicht geteilt.", + "grantedBy": "Erteilt von", + "expires": "Läuft ab", + "expired": "Abgelaufen", + "never": "Niemals", + "revoke": "Widerrufen", + "accessUpdated": "Zugriff aktualisiert", + "accessUpdateFailed": "Zugriff konnte nicht aktualisiert werden", + "sharedBadge": "Gemeinsam", + "sharedBadgeTooltip": "Gemeinsam genutzt von {{owner}} ({{level}} Zugriff)", + "viewOnlyBanner": "Dieser Host wird Ihnen von {{owner}} mit Lesezugriff zur Verfügung gestellt. Die Konfiguration ist schreibgeschützt.", + "sharedEditBanner": "Dieser Host wird Ihnen von {{owner}} mit Bearbeitungsrechten zur Verfügung gestellt. Änderungen wirken sich auf den eigentlichen Host aus; Authentifizierungsreferenzen können nur vom Eigentümer geändert werden.", + "ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern." }, "guac": { "connection": "Verbindung", @@ -1176,7 +1241,7 @@ "noCredential": "Keine Anmeldeinformationen (direkte Anmeldeinformationen siehe unten)", "authMethod": "Authentifizierungsmethode", "authTypeDirect": "Direkt", - "authTypeCredential": "Berechtigung", + "authTypeCredential": "Zugangsdaten", "selectCredential": "Wählen Sie eine Anmeldeinformation aus...", "connectionSettings": "Verbindungseinstellungen", "displaySettings": "Anzeigeeinstellungen", @@ -1184,9 +1249,9 @@ "rdpPerformance": "RDP-Leistung", "deviceRedirection": "Geräteumleitung", "session": "Sitzung", - "gateway": "Tor", + "gateway": "Gateway", "remoteApp": "RemoteApp", - "clipboard": "Klemmbrett", + "clipboard": "Zwischenablage", "sessionRecording": "Sitzungsaufzeichnung", "wakeOnLan": "Wake-on-LAN", "vncSettings": "VNC-Einstellungen", @@ -1194,23 +1259,23 @@ "rdpPort": "RDP-Port", "username": "Benutzername", "password": "Passwort", - "domain": "Domain", + "domain": "Domäne", "securityMode": "Sicherheitsmodus", "colorDepth": "Farbtiefe", "width": "Breite", "height": "Höhe", "dpi": "DPI", "resizeMethod": "Methode zum Ändern der Größe", - "clientName": "Kundenname", - "initialProgram": "Anfangsprogramm", + "clientName": "Clientname", + "initialProgram": "Startprogramm", "serverLayout": "Server-Layout", "timezone": "Zeitzone", "loadBalanceInfo": "Load-Balancing-Informationen / Cookie", "loadBalanceInfoDesc": "RD Connection Broker-Cookie für den Lastausgleich in der RDS-Farm (z. B. tsv://MS Terminal Services Plugin.1.CollectionName)", "guacdProxy": "guacd-Proxy", - "guacdHostname": "guacd Host", + "guacdHostname": "guacd-Host", "guacdHostnamePlaceholder": "Globaler Standardwert", - "guacdPort": "Guadalajara-Hafen", + "guacdPort": "guacd-Port", "guacdProxyDesc": "Überschreiben Sie die globale guacd-Instanz für diese Verbindung. Lassen Sie das Feld leer, um die global konfigurierte guacd-Instanz zu verwenden.", "gatewayHostname": "Gateway-Hostname", "gatewayPort": "Gateway-Port", @@ -1224,31 +1289,31 @@ "recordingPath": "Aufzeichnungspfad", "recordingName": "Aufnahmename", "macAddress": "MAC-Adresse", - "broadcastAddress": "Rundfunkansprache", + "broadcastAddress": "Broadcast-Adresse", "udpPort": "UDP-Port", "waitTimeS": "Wartezeit (s)", "driveName": "Laufwerksname", - "drivePath": "Fahrweg", + "drivePath": "Laufwerkspfad", "ignoreCertificate": "Zertifikat ignorieren", "ignoreCertificateDesc": "Verbindungen zu Hosts mit selbstsignierten Zertifikaten zulassen", - "forceLossless": "Kraftverlustfrei", + "forceLossless": "Verlustfreie Kodierung erzwingen", "forceLosslessDesc": "Verlustfreie Bildkodierung erzwingen (höhere Qualität, mehr Bandbreite)", "disableAudio": "Audio deaktivieren", "disableAudioDesc": "Schalten Sie alle Audiosignale der Remote-Sitzung stumm.", "enableAudioInput": "Audioeingang aktivieren (Mikrofon)", "enableAudioInputDesc": "Lokales Mikrofon an die Remote-Sitzung weiterleiten", - "wallpaper": "Tapete", + "wallpaper": "Hintergrundbild", "wallpaperDesc": "Desktop-Hintergrundbild anzeigen (Deaktivierung verbessert die Leistung)", - "theming": "Thematisierung", + "theming": "Design", "themingDesc": "Visuelle Designs und Stile aktivieren", "fontSmoothing": "Schriftglättung", "fontSmoothingDesc": "ClearType-Schriftwiedergabe aktivieren", - "fullWindowDrag": "Volles Fenster ziehen", + "fullWindowDrag": "Fensterinhalt beim Ziehen", "fullWindowDragDesc": "Fensterinhalt beim Ziehen anzeigen", - "desktopComposition": "Desktop-Aufstellung", + "desktopComposition": "Desktop-Komposition", "desktopCompositionDesc": "Aero-Glaseffekte aktivieren", "menuAnimations": "Menüanimationen", - "menuAnimationsDesc": "Menü-Ein- und Ausblendanimationen aktivieren", + "menuAnimationsDesc": "Einblend- und Schiebeanimationen für Menüs aktivieren", "disableBitmapCaching": "Bitmap-Caching deaktivieren", "disableBitmapCachingDesc": "Bitmap-Cache deaktivieren (kann bei Darstellungsfehlern helfen).", "disableOffscreenCaching": "Offscreen-Caching deaktivieren", @@ -1270,7 +1335,7 @@ "enableTouch": "Touch aktivieren", "enableTouchDesc": "Touch-Eingabeweiterleitung aktivieren", "consoleSession": "Konsolensitzung", - "consoleSessionDesc": "Verbinden Sie sich mit der Konsole (Sitzung 0) anstatt mit einer neuen Sitzung.", + "consoleSessionDesc": "Mit der Konsole (Sitzung 0) statt mit einer neuen Sitzung verbinden", "sendWolPacket": "WOL-Paket senden", "sendWolPacketDesc": "Senden Sie ein Magic Packet, um diesen Host aufzuwecken, bevor Sie die Verbindung herstellen.", "disableCopy": "Kopieren deaktivieren", @@ -1284,49 +1349,31 @@ "excludeMouse": "Maus ausschließen", "excludeMouseDesc": "Mausbewegungen nicht aufzeichnen", "includeKeystrokes": "Tastatureingaben einbeziehen", - "includeKeystrokesDesc": "Zeichnen Sie neben der Bildschirmausgabe auch die Roheingaben der Tastatur auf.", + "includeKeystrokesDesc": "Zusätzlich zur Bildschirmausgabe auch rohe Tastatureingaben aufzeichnen", "vncPort": "VNC-Port", "vncPassword": "VNC-Passwort", "vncUsernameOptional": "Benutzername (optional)", "vncLeaveBlank": "Lassen Sie dieses Feld leer, falls nicht erforderlich.", "cursorMode": "Cursor-Modus", "swapRedBlue": "Rot/Blau tauschen", - "swapRedBlueDesc": "Vertausche die roten und blauen Farbkanäle (behebt einige Farbprobleme).", + "swapRedBlueDesc": "Die roten und blauen Farbkanäle vertauschen (behebt einige Farbprobleme)", "readOnly": "Nur lesbar", "readOnlyDesc": "Den Remote-Bildschirm anzeigen, ohne Eingaben zu senden.", "telnetPort": "Telnet-Port", - "terminalType": "Anschlussart", + "terminalType": "Terminaltyp", "fontName": "Schriftartname", "fontSize": "Schriftgröße", "colorScheme": "Farbschema", "backspaceKey": "Rücktaste", - "saveHostFirst": "Rettet zuerst den Host.", + "saveHostFirst": "Speichern Sie zuerst den Host.", "sharingOptionsAfterSave": "Die Freigabeoptionen stehen zur Verfügung, nachdem der Host gespeichert wurde.", - "sharingLoadError": "Die Freigabedaten konnten nicht geladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", - "shareHostSection": "Share Host", - "shareWithUser": "Mit dem Nutzer teilen", - "shareWithRole": "Mit Rolle teilen", - "selectUser": "Benutzer auswählen", - "selectRole": "Rolle auswählen", - "selectUserOption": "Wählen Sie einen Benutzer aus...", - "selectRoleOption": "Wählen Sie eine Rolle aus...", "permissionLevel": "Berechtigungsstufe", - "expiresInHours": "Läuft ab in (Stunden)", - "noExpiryPlaceholder": "Für unbegrenztes Verfallsdatum leer lassen.", - "shareBtn": "Aktie", - "currentAccess": "Aktueller Zugriff", "typeHeader": "Typ", "targetHeader": "Ziel", - "permissionHeader": "Erlaubnis", - "grantedByHeader": "Genehmigt durch", - "expiresHeader": "Läuft ab", - "noAccessEntries": "Bisher keine Zugriffsrechte.", - "expiredLabel": "Abgelaufen", - "neverLabel": "Niemals", - "revokeBtn": "Widerrufen", - "cancelBtn": "Stornieren", + "permissionHeader": "Berechtigung", + "cancelBtn": "Abbrechen", "savingBtn": "Speichern...", - "updateHostBtn": "Update-Host", + "updateHostBtn": "Host aktualisieren", "addHostBtn": "Host hinzufügen" } }, @@ -1340,47 +1387,47 @@ "adminSettings": "Administratoreinstellungen", "adminSettingsDesc": "Systemeinstellungen und Benutzer konfigurieren", "userProfile": "Benutzerprofil", - "userProfileDesc": "Verwalten Sie Ihr Konto und Ihre Einstellungen", - "addCredential": "Anmeldeinformationen hinzufügen", - "addCredentialDesc": "Speichern Sie SSH-Schlüssel oder Passwörter", + "userProfileDesc": "Ihr Konto und Ihre Einstellungen verwalten", + "addCredential": "Zugangsdaten hinzufügen", + "addCredentialDesc": "SSH-Schlüssel oder Passwörter speichern", "tmuxMonitor": "Tmux-Monitor", "tmuxMonitorDesc": "Überwachen Sie tmux-Sitzungen auf Ihren Hosts.", - "recentActivity": "Aktuelle Aktivitäten", - "serversAndHosts": "Server & Hosts", - "noHostsFound": "Es wurden keine Hosts gefunden, die \"{{search}} \" entsprechen.", + "recentActivity": "Letzte Aktivitäten", + "serversAndHosts": "Server und Hosts", + "noHostsFound": "Keine Hosts gefunden, die „{{search}}“ entsprechen", "links": "Links", "navigate": "Navigieren", - "select": "Wählen", + "select": "Auswählen", "toggleWith": "Umschalten mit" }, "splitScreen": { - "paneEmpty": "Pane {{index}} - leer", + "paneEmpty": "Bereich {{index}} – leer", "noTabAssigned": "Kein Tab zugewiesen", "focusedPane": "Aktiver Bereich" }, "connections": { "noConnections": "Keine Verbindungen", "noConnectionsDesc": "Öffnen Sie ein Terminal, einen Dateimanager oder eine Remote-Desktop-Verbindung, um die Verbindungen hier anzuzeigen.", - "connectedFor": "Verbunden für {{duration}}", + "connectedFor": "Seit {{duration}} verbunden", "connected": "Verbunden", "disconnected": "Getrennt", "closeTab": "Tab schließen", - "closeConnection": "Enge Verbindung", + "closeConnection": "Verbindung schließen", "forgetTab": "Vergessen", "removeBackground": "Entfernen", - "reconnect": "Wiederverbinden", - "reopenTab": "Wieder öffnen", + "reconnect": "Erneut verbinden", + "reopenTab": "Erneut öffnen", "sectionOpen": "Offen", "sectionBackground": "Hintergrund", "backgroundDesc": "Die Sitzungen bleiben nach der Trennung noch 30 Minuten bestehen und können wiederhergestellt werden.", - "persisted": "Im Hintergrund bestehen geblieben", + "persisted": "Im Hintergrund fortgeführt", "expiresIn": "Läuft ab in {{duration}}", "search": "Verbindungen suchen...", - "noSearchResults": "Es wurden keine Ergebnisse gefunden, die Ihrer Suche entsprechen.", + "noSearchResults": "Keine Verbindungen entsprechen Ihrer Suche", "rename": "Sitzung umbenennen" }, "guacamole": { - "connecting": "Verbindung zur {{type}} Sitzung wird hergestellt...", + "connecting": "Verbindung zur {{type}}-Sitzung wird hergestellt...", "connectionError": "Verbindungsfehler", "connectionFailed": "Verbindung fehlgeschlagen", "failedToConnect": "Verbindungstoken konnte nicht abgerufen werden", @@ -1395,17 +1442,17 @@ "winL": "Win+L (Sperrbildschirm)", "winKey": "Windows-Taste", "ctrl": "Strg", - "alt": "Alternativ", - "shift": "Schicht", - "win": "Gewinnen", + "alt": "Alt", + "shift": "Umschalt", + "win": "Win", "stickyActive": "{{key}} (verriegelt - zum Entriegeln klicken)", "stickyInactive": "{{key}} (zum Verriegeln klicken)", - "esc": "Flucht", + "esc": "Esc", "tab": "Tab", - "home": "Heim", + "home": "Pos1", "end": "Ende", - "pageUp": "Seite nach oben", - "pageDown": "Seite nach unten", + "pageUp": "Bild auf", + "pageDown": "Bild ab", "arrowUp": "Pfeil nach oben", "arrowDown": "Pfeil nach unten", "arrowLeft": "Pfeil nach links", @@ -1419,15 +1466,15 @@ }, "terminal": { "connect": "Verbindung zum Host herstellen", - "clear": "Klar", - "paste": "Paste", - "reconnect": "Wiederverbinden", + "clear": "Leeren", + "paste": "Einfügen", + "reconnect": "Erneut verbinden", "connectionLost": "Verbindung unterbrochen", "connected": "Verbunden", "clipboardWriteFailed": "Fehler beim Kopieren in die Zwischenablage. Stellen Sie sicher, dass die Seite über HTTPS oder localhost bereitgestellt wird.", "clipboardReadFailed": "Fehler beim Lesen der Zwischenablage. Stellen Sie sicher, dass die Berechtigungen für die Zwischenablage erteilt sind.", - "clipboardHttpWarning": "Zum Einfügen ist HTTPS erforderlich. Verwenden Sie Strg+Umschalt+V oder verwenden Sie für Termix eine HTTPS-Verbindung.", - "passwordPromptFillTitle": "Geben Sie das gespeicherte Passwort in dieses Feld ein?", + "clipboardHttpWarning": "Zum Einfügen ist HTTPS erforderlich. Verwenden Sie Strg+Umschalt+V oder stellen Sie Termix über HTTPS bereit.", + "passwordPromptFillTitle": "Gespeichertes Passwort in diese Eingabeaufforderung einfügen?", "unknownError": "Es ist ein unbekannter Fehler aufgetreten.", "websocketError": "WebSocket-Verbindungsfehler", "connecting": "Verbindung wird hergestellt...", @@ -1436,18 +1483,18 @@ "reconnected": "Verbindung erfolgreich wiederhergestellt", "tmuxSessionCreated": "tmux-Sitzung erstellt: {{name}}", "tmuxSessionAttached": "tmux-Sitzung angehängt: {{name}}", - "tmuxUnavailable": "tmux ist auf dem Remote-Host nicht installiert, daher wird die Standard-Shell verwendet.", + "tmuxUnavailable": "tmux ist auf dem Remote-Host nicht installiert. Stattdessen wird die Standard-Shell verwendet.", "tmuxSessionPickerTitle": "tmux-Sitzungen", - "tmuxSessionPickerDesc": "Es wurden bestehende tmux-Sitzungen auf diesem Host gefunden. Wählen Sie eine aus, um sie wieder zu verbinden, oder erstellen Sie eine neue Sitzung.", + "tmuxSessionPickerDesc": "Auf diesem Host wurden vorhandene tmux-Sitzungen gefunden. Wählen Sie eine aus, um sie erneut anzuhängen, oder erstellen Sie eine neue Sitzung.", "tmuxWindows": "Windows", "tmuxWindowCount": "{{count}} Fenster", - "tmuxAttached": "Zugehörige Kunden", - "tmuxAttachedCount": "{{count}} angehängt", + "tmuxAttached": "Verbundene Clients", + "tmuxAttachedCount": "{{count}} verbunden", "tmuxLastActivity": "Letzte Aktivität", "tmuxTimeJustNow": "soeben", - "tmuxTimeMinutes": "{{count}}m ago", - "tmuxTimeHours": "{{count}}h ago", - "tmuxTimeDays": "{{count}}Tage her", + "tmuxTimeMinutes": "vor {{count}} Min.", + "tmuxTimeHours": "vor {{count}} Std.", + "tmuxTimeDays": "vor {{count}} T.", "tmuxCreateNew": "Neue Sitzung starten", "tmuxCopyHint": "Auswahl anpassen und Eingabetaste drücken, um in die Zwischenablage zu kopieren", "tmuxDetach": "tmux-Sitzung trennen", @@ -1457,7 +1504,7 @@ "connectionTimeout": "Verbindungstimeout", "terminalTitle": "Terminal - {{host}}", "terminalWithPath": "Terminal - {{host}}:{{path}}", - "runTitle": "Läuft {{command}} - {{host}}", + "runTitle": "{{command}} wird ausgeführt – {{host}}", "totpRequired": "Zwei-Faktor-Authentifizierung erforderlich", "totpCodeLabel": "Bestätigungscode", "totpVerify": "Verifizieren", @@ -1465,37 +1512,37 @@ "warpgateSecurityKey": "Sicherheitsschlüssel", "warpgateAuthUrl": "Authentifizierungs-URL", "warpgateOpenBrowser": "Im Browser öffnen", - "warpgateContinue": "Die Authentifizierung ist abgeschlossen.", + "warpgateContinue": "Authentifizierung abgeschlossen", "opksshAuthRequired": "OPKSSH-Authentifizierung erforderlich", "opksshAuthDescription": "Bitte authentifizieren Sie sich in Ihrem Browser, um fortzufahren. Diese Sitzung bleibt 24 Stunden lang gültig.", - "opksshOpenBrowser": "Öffnen Sie den Browser zur Authentifizierung", + "opksshOpenBrowser": "Browser zur Authentifizierung öffnen", "opksshWaitingForAuth": "Warten auf Authentifizierung im Browser...", "opksshAuthenticating": "Authentifizierung wird verarbeitet...", - "opksshTimeout": "Die Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.", + "opksshTimeout": "Zeitüberschreitung bei der Authentifizierung. Bitte versuchen Sie es erneut.", "opksshAuthFailed": "Die Authentifizierung ist fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten und versuchen Sie es erneut.", "opksshSignInWith": "Melden Sie sich mit {{provider}} an.", - "vaultAuthTitle": "Anmeldung im Tresorraum erforderlich", - "vaultAuthDescription": "Es hat sich ein Fenster zum Anmelden bei HashiCorp Vault geöffnet. Schließen Sie die Anmeldung dort ab; die Verbindung wird automatisch hergestellt.", + "vaultAuthTitle": "Vault-Anmeldung erforderlich", + "vaultAuthDescription": "Ein Fenster zur Anmeldung bei HashiCorp Vault wurde geöffnet. Schließen Sie dort die Anmeldung ab; diese Verbindung wird anschließend automatisch fortgesetzt.", "vaultAuthFailed": "Die Vault-Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.", "vaultReopen": "Anmeldefenster erneut öffnen", "sudoPasswordPopupTitle": "Passwort eingeben?", "linkDialogTitle": "Link öffnen", - "linkDialogOpen": "Offen", - "linkDialogCopy": "Kopie", + "linkDialogOpen": "Öffnen", + "linkDialogCopy": "Kopieren", "websocketAbnormalClose": "Die Verbindung wurde unerwartet geschlossen. Dies kann an einem Problem mit der Reverse-Proxy- oder SSL-Konfiguration liegen. Bitte überprüfen Sie die Serverprotokolle.", "connectionLogTitle": "Verbindungsprotokoll", "connectionLogCopy": "Protokolle in die Zwischenablage kopieren", "connectionLogEmpty": "Noch keine Verbindungsprotokolle", - "connectionLogWaiting": "Warte auf Verbindungsprotokolle...", + "connectionLogWaiting": "Warten auf Verbindungsprotokolle...", "connectionLogCopied": "Verbindungsprotokolle in die Zwischenablage kopiert", "connectionLogCopyFailed": "Fehler beim Kopieren der Protokolle in die Zwischenablage", "connectionRejected": "Die Verbindung wurde vom Server abgelehnt. Bitte überprüfen Sie Ihre Authentifizierungs- und Netzwerkkonfiguration.", "hostKeyRejected": "Die Überprüfung des SSH-Hostschlüssels wurde abgelehnt. Verbindung abgebrochen.", - "sessionTakenOver": "Die Sitzung wurde in einem neuen Tab geöffnet. Verbindung wird wiederhergestellt…", + "sessionTakenOver": "Die Sitzung wurde in einem anderen Tab geöffnet. Verbindung wird wiederhergestellt…", "split": { - "splitTab": "Split Tab", - "addToSplit": "Zu Split hinzufügen", - "removeFromSplit": "Aus Split entfernen" + "splitTab": "Tab teilen", + "addToSplit": "Zur geteilten Ansicht hinzufügen", + "removeFromSplit": "Aus der geteilten Ansicht entfernen" } }, "fileManager": { @@ -1506,33 +1553,33 @@ "uploadFile": "Datei hochladen", "downloadFile": "Herunterladen", "extractArchive": "Archiv extrahieren", - "extractingArchive": "Extrahiere {{name}}...", + "extractingArchive": "{{name}} wird extrahiert...", "archiveExtractedSuccessfully": "{{name}} erfolgreich extrahiert", "extractFailed": "Extraktion fehlgeschlagen", "compressFile": "Datei komprimieren", "compressFiles": "Dateien komprimieren", - "compressFilesDesc": "Komprimiere {{count}} Elemente in ein Archiv", + "compressFilesDesc": "{{count}} Elemente in ein Archiv komprimieren", "archiveName": "Archivname", "enterArchiveName": "Archivnamen eingeben...", "compressionFormat": "Komprimierungsformat", "selectedFiles": "Ausgewählte Dateien", "andMoreFiles": "und {{count}} mehr...", - "compress": "Kompresse", - "compressingFiles": "Komprimierung von {{count}} Elementen in {{name}}...", + "compress": "Komprimieren", + "compressingFiles": "{{count}} Elemente werden in {{name}} komprimiert...", "filesCompressedSuccessfully": "{{name}} erfolgreich erstellt", "compressFailed": "Kompression fehlgeschlagen", "edit": "Bearbeiten", "preview": "Vorschau", - "previous": "Vorherige", - "next": "Nächste", + "previous": "Zurück", + "next": "Weiter", "pageXOfY": "Seite {{current}} von {{total}}", - "zoomOut": "Herauszoomen", + "zoomOut": "Verkleinern", "zoomIn": "Vergrößern", "newFile": "Neue Datei", "newFolder": "Neuer Ordner", "rename": "Umbenennen", "uploading": "Wird hochgeladen...", - "uploadingFile": "Hochladen {{name}}...", + "uploadingFile": "{{name}} wird hochgeladen...", "fileName": "Dateiname", "folderName": "Ordnername", "fileUploadedSuccessfully": "Datei \"{{name}}\" erfolgreich hochgeladen", @@ -1541,8 +1588,8 @@ "failedToDownloadFile": "Datei konnte nicht heruntergeladen werden", "fileCreatedSuccessfully": "Datei \"{{name}}\" erfolgreich erstellt", "folderCreatedSuccessfully": "Ordner \"{{name}}\" erfolgreich erstellt", - "failedToCreateItem": "Fehler beim Erstellen des Artikels", - "operationFailed": "{{operation}} Vorgang für {{name}}fehlgeschlagen: {{error}}", + "failedToCreateItem": "Element konnte nicht erstellt werden", + "operationFailed": "Vorgang „{{operation}}“ für {{name}} fehlgeschlagen: {{error}}", "failedToResolveSymlink": "Symlink konnte nicht aufgelöst werden", "itemsDeletedSuccessfully": "{{count}} Elemente erfolgreich gelöscht", "failedToDeleteItems": "Fehler beim Löschen der Elemente", @@ -1551,7 +1598,7 @@ "sudoPassword": "Sudo-Passwort", "sudoOperationFailed": "Sudo-Operation fehlgeschlagen", "sudoAuthFailed": "Sudo-Authentifizierung fehlgeschlagen", - "dragFilesToUpload": "Dateien hier ablegen zum Hochladen", + "dragFilesToUpload": "Dateien zum Hochladen hier ablegen", "emptyFolder": "Dieser Ordner ist leer", "searchFiles": "Dateien durchsuchen...", "upload": "Hochladen", @@ -1560,77 +1607,77 @@ "failedToConnect": "SSH-Verbindung fehlgeschlagen", "failedToLoadDirectory": "Verzeichnis konnte nicht geladen werden", "noSSHConnection": "Keine SSH-Verbindung verfügbar", - "copy": "Kopie", - "cut": "Schneiden", - "paste": "Paste", - "copyPath": "Kopierpfad", + "copy": "Kopieren", + "cut": "Ausschneiden", + "paste": "Einfügen", + "copyPath": "Pfad kopieren", "copyPaths": "Pfade kopieren", "delete": "Löschen", "properties": "Eigenschaften", "refresh": "Aktualisieren", - "downloadFiles": "Laden Sie {{count}} Dateien in den Browser herunter", - "copyFiles": "Kopieren Sie {{count}} Elemente", - "cutFiles": "Schneide {{count}} Elemente", - "deleteFiles": "Löschen Sie {{count}} Elemente", + "downloadFiles": "{{count}} Dateien in den Browser herunterladen", + "copyFiles": "{{count}} Elemente kopieren", + "cutFiles": "{{count}} Elemente ausschneiden", + "deleteFiles": "{{count}} Elemente löschen", "filesCopiedToClipboard": "{{count}} Elemente in die Zwischenablage kopiert", "filesCutToClipboard": "{{count}} Elemente in die Zwischenablage ausgeschnitten", "pathCopiedToClipboard": "Pfad in die Zwischenablage kopiert", "pathsCopiedToClipboard": "{{count}} Pfade in die Zwischenablage kopiert", "failedToCopyPath": "Pfad konnte nicht in die Zwischenablage kopiert werden", "copyFolderLink": "Link zum Ordner kopieren", - "copyCurrentFolderLink": "Link in aktuellen Ordner kopieren", + "copyCurrentFolderLink": "Link zum aktuellen Ordner kopieren", "folderLinkCopied": "Ordnerlink in die Zwischenablage kopiert", "failedToCopyFolderLink": "Fehler beim Kopieren des Ordnerlinks", - "movedItems": "Verschobene {{count}} Elemente", + "movedItems": "{{count}} Elemente verschoben", "failedToDeleteItem": "Fehler beim Löschen des Elements", "itemRenamedSuccessfully": "{{type}} erfolgreich umbenannt", "failedToRenameItem": "Fehler beim Umbenennen des Elements", "download": "Herunterladen", - "openExternalEditor": "Von außen öffnen", + "openExternalEditor": "Extern öffnen", "chooseExternalEditor": "Externen Editor auswählen", "externalEditorSelected": "Externer Editor ausgewählt", "externalEditorDesktopOnly": "Der externe Editor ist nur in der Desktop-App verfügbar.", "externalEditorOpened": "In einem externen Editor geöffnet. Wenn Sie dort speichern, werden die Änderungen zurück auf den Server hochgeladen.", "failedToOpenExternalEditor": "Externer Editor konnte nicht geöffnet werden", - "failedToSelectExternalEditor": "Externen Editor konnte nicht ausgewählt werden", + "failedToSelectExternalEditor": "Externer Editor konnte nicht ausgewählt werden", "permissions": "Berechtigungen", "size": "Größe", - "modified": "Modifiziert", - "path": "Weg", + "modified": "Geändert", + "path": "Pfad", "confirmDelete": "Möchten Sie {{name}} wirklich löschen?", "permissionDenied": "Zugriff verweigert", "serverError": "Serverfehler", "fileSavedSuccessfully": "Datei erfolgreich gespeichert", "failedToSaveFile": "Datei konnte nicht gespeichert werden", - "confirmDeleteSingleItem": "Sind Sie sicher, dass Sie \"{{name}} \" endgültig löschen möchten?", + "confirmDeleteSingleItem": "Möchten Sie „{{name}}“ wirklich endgültig löschen?", "confirmDeleteMultipleItems": "Möchten Sie {{count}} Elemente wirklich endgültig löschen?", "confirmDeleteMultipleItemsWithFolders": "Sind Sie sicher, dass Sie {{count}} Elemente endgültig löschen möchten? Dies umfasst Ordner und deren Inhalt.", "confirmDeleteFolder": "Sind Sie sicher, dass Sie den Ordner \"{{name}}\" und seinen gesamten Inhalt endgültig löschen möchten?", "permanentDeleteWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Die betreffenden Elemente werden endgültig vom Server gelöscht.", - "recent": "Jüngste", + "recent": "Zuletzt verwendet", "pinned": "Angepinnt", "folderShortcuts": "Ordnerverknüpfungen", - "failedToReconnectSSH": "Die SSH-Sitzung konnte nicht wiederhergestellt werden.", + "failedToReconnectSSH": "Verbindung zur SSH-Sitzung konnte nicht wiederhergestellt werden", "openTerminalHere": "Terminal hier öffnen", - "run": "Laufen", + "run": "Ausführen", "openTerminalInFolder": "Terminal in diesem Ordner öffnen", "openTerminalInFileLocation": "Terminal am Dateispeicherort öffnen", - "runningFile": "Läuft - {{file}}", + "runningFile": "Wird ausgeführt – {{file}}", "onlyRunExecutableFiles": "Es können nur ausführbare Dateien ausgeführt werden.", "directories": "Verzeichnisse", - "removedFromRecentFiles": "\"{{name}}\" aus den letzten Dateien entfernt", + "removedFromRecentFiles": "„{{name}}“ aus den zuletzt verwendeten Dateien entfernt", "removeFailed": "Entfernen fehlgeschlagen", - "unpinnedSuccessfully": "Entfesselt \"{{name}}\" erfolgreich", + "unpinnedSuccessfully": "Anheftung von „{{name}}“ erfolgreich aufgehoben", "unpinFailed": "Entpinnung fehlgeschlagen", - "removedShortcut": "Entfernte Tastenkombination \"{{name}}\"", - "removeShortcutFailed": "Verknüpfung entfernen fehlgeschlagen", + "removedShortcut": "Verknüpfung „{{name}}“ entfernt", + "removeShortcutFailed": "Verknüpfung konnte nicht entfernt werden", "clearedAllRecentFiles": "Alle zuletzt verwendeten Dateien wurden gelöscht", "clearFailed": "Löschen fehlgeschlagen", "removeFromRecentFiles": "Aus den zuletzt verwendeten Dateien entfernen", "clearAllRecentFiles": "Alle zuletzt verwendeten Dateien löschen", - "unpinFile": "Datei aufheben", + "unpinFile": "Anheftung der Datei aufheben", "removeShortcut": "Verknüpfung entfernen", - "pinFile": "PIN-Datei", + "pinFile": "Datei anheften", "addToShortcuts": "Zu Verknüpfungen hinzufügen", "pasteFailed": "Einfügen fehlgeschlagen", "noUndoableActions": "Keine rückgängig zu machenden Aktionen", @@ -1641,29 +1688,29 @@ "undoMoveFailedMove": "Rückgängigmachen fehlgeschlagen: Es konnten keine Dateien zurückverschoben werden.", "undoMoveFailedNoInfo": "Rückgängigmachen fehlgeschlagen: Verschobene Dateiinformationen konnten nicht gefunden werden.", "undoDeleteNotSupported": "Der Löschvorgang kann nicht rückgängig gemacht werden: Die Dateien wurden endgültig vom Server gelöscht.", - "undoTypeNotSupported": "Nicht unterstützter Rückgängig-Operationstyp", - "undoOperationFailed": "Rückgängigmachen-Vorgang fehlgeschlagen", + "undoTypeNotSupported": "Nicht unterstützter Vorgang zum Rückgängigmachen", + "undoOperationFailed": "Rückgängigmachen fehlgeschlagen", "unknownError": "Unbekannter Fehler", "confirm": "Bestätigen", - "find": "Finden...", + "find": "Suchen...", "replace": "Ersetzen", "downloadInstead": "Stattdessen herunterladen", "keyboardShortcuts": "Tastenkombinationen", "searchAndReplace": "Suchen und Ersetzen", - "editing": "Bearbeitung", + "editing": "Wird bearbeitet", "search": "Suchen", - "findNext": "Weiter", - "findPrevious": "Vorherigen Beitrag finden", + "findNext": "Weitersuchen", + "findPrevious": "Rückwärts suchen", "save": "Speichern", "selectAll": "Alle auswählen", "undo": "Rückgängig machen", "redo": "Wiederholen", - "moveLineUp": "Aufstellung verschieben", - "moveLineDown": "Linie nach unten verschieben", - "toggleComment": "Kommentar ein-/ausblenden", - "autoComplete": "Automatische Vervollständigung", + "moveLineUp": "Zeile nach oben verschieben", + "moveLineDown": "Zeile nach unten verschieben", + "toggleComment": "Kommentar umschalten", + "autoComplete": "Autovervollständigung", "imageLoadError": "Bild konnte nicht geladen werden", - "startTyping": "Beginnen Sie mit der Eingabe...", + "startTyping": "Mit der Eingabe beginnen...", "unknownSize": "Unbekannte Größe", "fileIsEmpty": "Die Datei ist leer.", "largeFileWarning": "Warnung vor großer Datei", @@ -1677,35 +1724,35 @@ "moveOperationFailed": "Verschiebungsvorgang fehlgeschlagen", "canOnlyCompareFiles": "Es können nur zwei Dateien verglichen werden.", "comparingFiles": "Dateien werden verglichen: {{file1}} und {{file2}}", - "dragFailed": "Drag-Vorgang fehlgeschlagen", + "dragFailed": "Ziehvorgang fehlgeschlagen", "filePinnedSuccessfully": "Datei \"{{name}}\" erfolgreich angeheftet", "pinFileFailed": "Datei konnte nicht angeheftet werden", - "fileUnpinnedSuccessfully": "Datei \"{{name}}\" erfolgreich gelöst", + "fileUnpinnedSuccessfully": "Anheftung der Datei „{{name}}“ erfolgreich aufgehoben", "unpinFileFailed": "Datei konnte nicht gelöst werden", "shortcutAddedSuccessfully": "Ordnerverknüpfung \"{{name}}\" erfolgreich hinzugefügt", "addShortcutFailed": "Verknüpfung konnte nicht hinzugefügt werden", - "operationCompletedSuccessfully": "{{operation}} {{count}} Elemente erfolgreich", - "operationCompleted": "{{operation}} {{count}} Artikel", + "operationCompletedSuccessfully": "{{operation}} für {{count}} Elemente erfolgreich abgeschlossen", + "operationCompleted": "{{operation}}: {{count}} Elemente", "downloadFileSuccess": "Datei {{name}} erfolgreich heruntergeladen", "downloadFileFailed": "Download fehlgeschlagen", - "moveTo": "Bewegen Sie sich zu {{name}}", - "diffCompareWith": "Diff vergleichen mit {{name}}", - "dragOutsideToDownload": "Ziehen Sie außerhalb des Fensters, um die Dateien herunterzuladen ({{count}})", - "newFolderDefault": "Neuer Ordner", - "newFileDefault": "NewFile.txt", + "moveTo": "Nach {{name}} verschieben", + "diffCompareWith": "Diff mit {{name}} vergleichen", + "dragOutsideToDownload": "Zum Herunterladen aus dem Fenster ziehen ({{count}} Dateien)", + "newFolderDefault": "NeuerOrdner", + "newFileDefault": "NeueDatei.txt", "successfullyMovedItems": "{{count}} Elemente erfolgreich nach {{target}} verschoben.", - "move": "Bewegen", + "move": "Verschieben", "searchInFile": "In Datei suchen (Strg+F)", "showKeyboardShortcuts": "Tastenkombinationen anzeigen", "decreaseFontSize": "Schriftgröße verringern", "increaseFontSize": "Schriftgröße vergrößern", - "startWritingMarkdown": "Beginnen Sie mit dem Schreiben Ihres Markdown-Inhalts...", + "startWritingMarkdown": "Beginnen Sie mit der Eingabe Ihres Markdown-Inhalts...", "loadingFileComparison": "Dateivergleich wird geladen...", "reload": "Neu laden", "compare": "Vergleichen", "sideBySide": "Nebeneinander", - "inline": "Im Einklang", - "fileComparison": "Dateivergleich: {{file1}} vs {{file2}}", + "inline": "Inline", + "fileComparison": "Dateivergleich: {{file1}} mit {{file2}}", "fileTooLarge": "Datei zu groß: {{error}}", "sshConnectionFailed": "SSH-Verbindung fehlgeschlagen. Bitte überprüfen Sie Ihre Verbindung zu {{name}} ({{ip}}:{{port}})", "loadFileFailed": "Datei konnte nicht geladen werden: {{error}}", @@ -1714,7 +1761,7 @@ "totpVerificationFailed": "TOTP-Verifizierung fehlgeschlagen", "warpgateVerificationFailed": "Warpgate-Authentifizierung fehlgeschlagen", "authenticationFailed": "Authentifizierung fehlgeschlagen", - "incorrectPassphrase": "Falsches Passwort. Bitte versuchen Sie es erneut.", + "incorrectPassphrase": "Falsche Passphrase. Bitte versuchen Sie es erneut.", "verificationCodePrompt": "Bestätigungscode:", "changePermissions": "Berechtigungen ändern", "currentPermissions": "Aktuelle Berechtigungen", @@ -1732,16 +1779,16 @@ "sortBySize": "Größe", "ascending": "Aufsteigend", "descending": "Absteigend", - "root": "Wurzel", + "root": "Root", "new": "Neu", "sortBy": "Sortieren nach", - "items": "Artikel", + "items": "Elemente", "selected": "Ausgewählt", "editor": "Editor", "octal": "Oktal", - "storage": "Lagerung", - "disk": "Scheibe", - "used": "Gebraucht", + "storage": "Speicher", + "disk": "Datenträger", + "used": "Belegt", "of": "von", "toggleSidebar": "Seitenleiste ein-/ausblenden", "cannotLoadPdf": "PDF kann nicht geladen werden", @@ -1751,108 +1798,108 @@ }, "transfer": { "copyToHost": "Zum Host kopieren…", - "moveToHost": "Zum Host wechseln…", - "copyItemsToHost": "Kopiere {{count}} Elemente in den Host…", - "moveItemsToHost": "Verschiebe {{count}} Elemente in den Host…", + "moveToHost": "Auf einen Host verschieben…", + "copyItemsToHost": "{{count}} Elemente auf einen Host kopieren…", + "moveItemsToHost": "{{count}} Elemente auf Host verschieben…", "noHostsConnected": "Es sind keine anderen Dateimanager-Hosts verfügbar.", "noHostsConnectedHint": "Fügen Sie im Host-Manager einen weiteren SSH-Host mit aktiviertem Dateimanager hinzu.", "selectDestinationHost": "Zielhost auswählen", "destinationPath": "Zielpfad", - "recentDestinations": "Aktuelle Reiseziele", - "collapseRecentDestinations": "Aktuelle Reiseziele ausblenden", - "expandRecentDestinations": "Aktuelle Reiseziele erweitern", + "recentDestinations": "Zuletzt verwendete Ziele", + "collapseRecentDestinations": "Zuletzt verwendete Ziele einklappen", + "expandRecentDestinations": "Zuletzt verwendete Ziele ausklappen", "browseFolders": "Zielordner durchsuchen", "browseDestination": "Pfad durchsuchen oder eingeben", - "confirmCopy": "Kopie", - "confirmMove": "Bewegen", - "transferring": "Übertragen…", - "compressing": "Komprimierung…", - "extracting": "Extrahiere…", + "confirmCopy": "Kopieren", + "confirmMove": "Verschieben", + "transferring": "Wird übertragen…", + "compressing": "Wird komprimiert…", + "extracting": "Wird extrahiert…", "transferringItems": "Übertrage {{current}} von {{total}} Elementen…", "transferSuccess": "Übertragung abgeschlossen", "transferError": "Übertragung fehlgeschlagen", "transferPartial": "Übertragung mit {{count}} Fehlern abgeschlossen", "transferPartialHint": "Übertragung fehlgeschlagen: {{paths}}", - "itemsSummary": "{{count}} Artikel", - "destMustBeDirectory": "Das Ziel muss ein Verzeichnis für die Übertragung mehrerer Elemente sein.", + "itemsSummary": "{{count}} Elemente", + "destMustBeDirectory": "Das Ziel muss bei der Übertragung mehrerer Elemente ein Verzeichnis sein", "selectThisFolder": "Wählen Sie diesen Ordner aus", "browsePathWillBeCreated": "Dieser Ordner existiert noch nicht. Er wird erstellt, sobald die Übertragung beginnt.", "browsePathError": "Dieser Pfad konnte auf dem Zielhost nicht geöffnet werden.", - "goUp": "Steigen", + "goUp": "Nach oben", "copyFolderToHost": "Ordner auf Host kopieren…", - "moveFolderToHost": "Ordner zum Host verschieben…", + "moveFolderToHost": "Ordner auf Host verschieben…", "hostReady": "Bereit", - "hostConnecting": "Verbindet…", + "hostConnecting": "Verbindung wird hergestellt…", "hostDisconnected": "Nicht verbunden", "hostAuthRequired": "Authentifizierung erforderlich – öffnen Sie zuerst den Dateimanager auf diesem Host.", "hostConnectionFailed": "Verbindung fehlgeschlagen", "metricsTitle": "Übertragungszeiten", "metricsPrepare": "Ziel vorbereiten: {{duration}}", - "metricsCompress": "Komprimierung auf der Quelle: {{duration}}", + "metricsCompress": "Komprimierung auf der Quellseite: {{duration}}", "metricsHopSourceRead": "Quelle → Server: {{throughput}}", "metricsHopDestSftpWrite": "Server → Ziel (SFTP): {{throughput}}", "metricsHopDestLocalWrite": "Server → Ziel (lokal): {{throughput}}", "metricsTransfer": "End-to-End: {{throughput}} ({{duration}})", - "metricsExtract": "Auszug am Zielort: {{duration}}", + "metricsExtract": "Am Ziel extrahieren: {{duration}}", "metricsSourceDelete": "Aus der Quelle entfernen: {{duration}}", "metricsTotal": "Gesamt: {{duration}}", "progressCompressing": "Komprimierung auf dem Quellhost…", "progressExtracting": "Extraktion am Ziel…", "progressTransferring": "Datenübertragung…", "progressReconnecting": "Verbindung wird wiederhergestellt…", - "parallelSegmentsLabel": "Parallele Übergabespuren", - "parallelSegmentsOption": "{{count}} Spuren", - "parallelSegmentsHint": "Große Dateien werden in 256 MB große Teile aufgeteilt. Mehrere Übertragungswege nutzen separate Verbindungen (ähnlich wie beim Starten mehrerer Übertragungen), um einen höheren Gesamtdurchsatz zu erzielen.", - "progressTotalSpeed": "{{speed}} Gesamt ({{lanes}} Spuren)", - "progressTransferringItems": "Übertrage Dateien ({{current}} von {{total}})…", + "parallelSegmentsLabel": "Parallele Übertragungskanäle", + "parallelSegmentsOption": "{{count}} Kanäle", + "parallelSegmentsHint": "Große Dateien werden in 256-MB-Blöcke aufgeteilt. Mehrere Kanäle verwenden separate Verbindungen (wie mehrere gleichzeitig gestartete Übertragungen), um einen höheren Gesamtdurchsatz zu erzielen.", + "progressTotalSpeed": "{{speed}} insgesamt ({{lanes}} Kanäle)", + "progressTransferringItems": "Dateien werden übertragen ({{current}} von {{total}})…", "progressBytes": "{{transferred}} / {{total}}", "progressItems": "{{current}} / {{total}} Dateien", "sourceNotDeletedPartial": "Quelldateien beibehalten (teilweise Übertragung)", "jumpHostLimitation": "Beide Hosts müssen vom Termix-Server aus erreichbar sein. Direktes Host-zu-Host-Routing wird nicht unterstützt.", - "cancel": "Stornieren", + "cancel": "Abbrechen", "methodLabel": "Übertragungsmethode", "methodAuto": "Auto", "methodTar": "Tar-Archiv", "methodItemSftp": "SFTP pro Datei", - "methodAutoHint": "Wählt je nach Dateianzahl, -größe und Komprimierbarkeit entweder tar-Archive oder dateibasierte SFTP-Dateien aus. Einzelne Dateien werden immer per Streaming-SFTP übertragen.", + "methodAutoHint": "Wählt anhand von Dateianzahl, Größe und Komprimierbarkeit zwischen einem tar-Archiv und SFTP pro Datei. Einzelne Dateien werden immer per SFTP-Streaming übertragen.", "methodTarHint": "Komprimieren auf dem Quellsystem, ein Archiv übertragen, auf dem Zielsystem extrahieren. Erfordert tar auf beiden Unix-Hosts.", "methodItemSftpHint": "Übertragen Sie jede Datei einzeln per SFTP. Funktioniert auf allen Hosts, einschließlich Windows.", - "methodPreviewLoading": "Berechnung der Transfermethode…", + "methodPreviewLoading": "Übertragungsmethode wird berechnet…", "methodPreviewError": "Die Übertragungsmethode konnte nicht in der Vorschau angezeigt werden. Der Server wählt beim Start eine Methode aus.", "methodPreviewWillUseTar": "Verwendet wird: Tar-Archiv", "methodPreviewWillUseItemSftp": "Wird verwendet: SFTP pro Datei", "methodPreviewScanSummary": "{{fileCount}} Dateien, {{totalSize}} insgesamt (auf dem Quellhost gescannt).", - "methodItemSftpLimitation": "Jede Datei nutzt denselben SFTP-Stream als Einzeldateikopie, die nacheinander erstellt werden. Der Fortschritt wird über alle Dateien hinweg kombiniert, daher bewegt sich der Balken bei großen Dateien nur langsam.", + "methodItemSftpLimitation": "Jede Datei wird nacheinander über denselben SFTP-Stream wie bei einer Einzeldateikopie übertragen. Der Fortschritt wird über alle Dateien zusammengefasst, sodass sich der Balken bei großen Dateien nur langsam bewegt.", "methodReason": { "user_item_sftp": "Sie haben SFTP pro Datei ausgewählt.", "user_tar": "Sie haben das Tar-Archiv ausgewählt.", "tar_unavailable": "Tar ist auf einem oder beiden Hosts nicht verfügbar – stattdessen wird SFTP pro Datei verwendet.", "windows_host": "Es handelt sich um einen Windows-Host – tar wird nicht verwendet.", - "auto_multi_large": "Auto: Mehrere Dateien, einschließlich einer großen Datei ({{largestSize}}) mit komprimierbaren Daten - tar-Bundles in einer Übertragung.", - "auto_single_large_in_archive": "Auto: eine große Datei ({{largestSize}}) in diesem Satz — dateibasiertes SFTP.", - "auto_many_incompressible": "Auto: größtenteils nicht komprimierbare Daten — dateibasiertes SFTP.", - "auto_many_files": "Auto: viele Dateien ({{fileCount}}) — tar reduziert den Overhead pro Datei.", - "auto_default": "Auto: SFTP pro Datei für diesen Satz." + "auto_multi_large": "Auto: mehrere Dateien, darunter eine große Datei ({{largestSize}}), mit komprimierbaren Daten – tar fasst sie zu einer Übertragung zusammen.", + "auto_single_large_in_archive": "Auto: eine große Datei ({{largestSize}}) in dieser Auswahl – SFTP pro Datei.", + "auto_many_incompressible": "Auto: größtenteils nicht komprimierbare Daten – SFTP pro Datei.", + "auto_many_files": "Auto: viele Dateien ({{fileCount}}) – tar reduziert den Aufwand pro Datei.", + "auto_default": "Auto: SFTP pro Datei für diese Auswahl." }, - "progressCancel": "Stornieren", - "progressCancelling": "Abbrechen…", - "progressStalled": "Stillgelegt", + "progressCancel": "Abbrechen", + "progressCancelling": "Wird abgebrochen…", + "progressStalled": "Ins Stocken geraten", "resumedHint": "Die Verbindung zu einer in einem anderen Fenster gestarteten aktiven Übertragung wurde wiederhergestellt.", - "transferCancelled": "Transfer storniert", + "transferCancelled": "Übertragung abgebrochen", "transferCancelledCopyHint": "Es können unvollständige Dateien am Zielort verbleiben.", "transferCancelledMoveHint": "Es können noch unvollständige Dateien auf dem Zielsystem vorhanden sein. Die Quelldateien wurden nicht entfernt.", - "cleanupDestFiles": "Zielort aufräumen", - "cleanupDestFilesSuccess": "Teilweise Dateien aus dem Zielverzeichnis entfernt", + "cleanupDestFiles": "Zieldateien bereinigen", + "cleanupDestFilesSuccess": "Unvollständige Dateien vom Ziel entfernt", "cleanupDestFilesPartial": "Einige unvollständige Dateien konnten nicht entfernt werden.", "cleanupDestFilesNothing": "Am Zielort gibt es nichts aufzuräumen.", "cleanupDestFilesError": "Bereinigung fehlgeschlagen", "retryTransfer": "Wiederholen", "retryTransferError": "Wiederholungsversuch fehlgeschlagen", - "transferFailedRetryHint": "Auf dem Zielserver wurden nur Teildaten gespeichert. Der Verbindungsversuch wird fortgesetzt, sobald die Verbindung wiederhergestellt ist." + "transferFailedRetryHint": "Unvollständige Daten wurden am Ziel beibehalten. Nach Wiederherstellung der Verbindung wird die Übertragung beim erneuten Versuch fortgesetzt." }, "tunnels": { "noSshTunnels": "Keine SSH-Tunnel", - "createFirstTunnelMessage": "Sie haben noch keine SSH-Tunnel erstellt. Konfigurieren Sie Tunnelverbindungen im Host-Manager, um zu beginnen.", + "createFirstTunnelMessage": "Sie haben noch keine SSH-Tunnel erstellt. Konfigurieren Sie Tunnelverbindungen im Host-Manager, um loszulegen.", "connected": "Verbunden", "disconnected": "Getrennt", "connecting": "Verbindung wird hergestellt...", @@ -1860,9 +1907,9 @@ "canceling": "Wird abgebrochen...", "connect": "Verbinden", "disconnect": "Trennen", - "cancel": "Stornieren", - "port": "Hafen", - "localPort": "Lokaler Hafen", + "cancel": "Abbrechen", + "port": "Port", + "localPort": "Lokaler Port", "remotePort": "Remote-Port", "currentHostPort": "Aktueller Host-Port", "endpointPort": "Endpunkt-Port", @@ -1881,8 +1928,8 @@ "remoteHost": "Remote-Host", "autoStart": "Automatischer Start", "clientAutoStartDesc": "Startet, sobald dieser Desktop-Client geöffnet wird, und bleibt verbunden.", - "clientManualStartDesc": "Verwenden Sie die Schaltflächen „Start“ und „Stopp“ in dieser Zeile. Termix öffnet sie nicht automatisch.", - "clientRemoteServerNote": "Für die Weiterleitung an entfernte Ziele sind möglicherweise die Berechtigungen „AllowTcpForwarding“ und „GatewayPorts“ auf dem SSH-Server des Endpunkts erforderlich. Der entfernte Port wird geschlossen, sobald die Verbindung dieses Desktops getrennt wird.", + "clientManualStartDesc": "Verwenden Sie in dieser Zeile „Start“ und „Stopp“. Termix öffnet den Tunnel nicht automatisch.", + "clientRemoteServerNote": "Remote-Weiterleitungen erfordern auf dem SSH-Server des Endpunkts möglicherweise AllowTcpForwarding und GatewayPorts. Der Remote-Port wird geschlossen, sobald die Verbindung dieses Desktops getrennt wird.", "clientTunnelStarted": "Client-Tunnel gestartet", "clientTunnelStopped": "Client-Tunnel gestoppt", "tunnelTestSucceeded": "Tunneltest erfolgreich", @@ -1903,26 +1950,26 @@ "test": "Prüfen", "type": "Tunneltyp", "typeLocal": "Lokal (-L)", - "typeRemote": "Fernbedienung (-R)", + "typeRemote": "Remote (-R)", "typeDynamic": "Dynamisch (-D)", "typeServerLocalDesc": "Vom aktuellen Host zum Endpunkt.", "typeServerRemoteDesc": "Endpunkt zurück zum aktuellen Host.", "typeClientLocalDesc": "Lokaler Computer zum Endpunkt.", "typeClientRemoteDesc": "Endpunkt zurück zum lokalen Computer.", "typeClientDynamicDesc": "SOCKS auf dem lokalen Computer.", - "typeDynamicDesc": "SOCKS5 CONNECT-Datenverkehr über SSH weiterleiten", + "typeDynamicDesc": "SOCKS5-CONNECT-Datenverkehr über SSH weiterleiten", "forwardDescriptionServerLocal": "Aktueller Host {{sourcePort}} → Endpunkt {{endpointPort}}.", "forwardDescriptionServerRemote": "Endpunkt {{endpointPort}} → aktueller Host {{sourcePort}}.", "forwardDescriptionServerDynamic": "SOCKS auf dem aktuellen Host {{sourcePort}}.", "forwardDescriptionClientLocal": "Lokal {{sourcePort}} → Remote {{endpointPort}}.", - "forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.", + "forwardDescriptionClientRemote": "Remote {{sourcePort}} → lokal {{endpointPort}}.", "forwardDescriptionClientDynamic": "SOCKS auf lokalem Port {{sourcePort}}.", "summaryClientLocal": "{{localHost}}:{{localPort}} → {{endpoint}}:{{remotePort}}", "summaryClientRemote": "{{endpoint}}:{{remotePort}} → {{localHost}}:{{localPort}}", - "summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}", + "summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS über {{endpoint}}", "autoNameClientLocal": "Lokal {{localPort}} → {{endpoint}} {{remotePort}}", - "autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}", - "autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}", + "autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokal {{localPort}}", + "autoNameClientDynamic": "SOCKS {{localPort}} über {{endpoint}}", "route": "Route:", "lastStarted": "Zuletzt gestartet", "lastTested": "Zuletzt getestet", @@ -1932,26 +1979,26 @@ "retryInterval": "Wiederholungsintervall (Sekunden)", "retryIntervalDescription": "Wartezeit zwischen den Wiederholungsversuchen.", "local": "Lokal", - "remote": "Fernbedienung", + "remote": "Remote", "destination": "Ziel", - "host": "Gastgeber", + "host": "Host", "mode": "Modus", "noHostSelected": "Kein Host ausgewählt", - "working": "Arbeiten..." + "working": "Wird ausgeführt..." }, "cardGrid": { "dragToMove": "Zum Verschieben ziehen", - "dragToResize": "Zum Größenänderung ziehen.", + "dragToResize": "Zum Ändern der Größe ziehen", "changeWidth": "Breite ändern", - "removeCard": "Entferne {{label}}", + "removeCard": "{{label}} entfernen", "addCard": "Hinzufügen", "columns": "Spalten", - "empty": "Keine Karten. Verwenden Sie „Hier hinzufügen“, um Karten zu platzieren." + "empty": "Keine Karten. Verwenden Sie unten „Hinzufügen“, um Karten zu platzieren." }, "hostMetrics": { "cpu": "CPU", - "memory": "Erinnerung", - "disk": "Scheibe", + "memory": "Arbeitsspeicher", + "disk": "Datenträger", "network": "Netzwerk", "uptime": "Betriebszeit", "processes": "Prozesse", @@ -1959,19 +2006,19 @@ "free": "Frei", "connecting": "Verbindung wird hergestellt...", "connectionFailed": "Verbindung zum Server fehlgeschlagen", - "naCpus": "Nicht verfügbar CPU(s)", + "naCpus": "CPU(s): n. v.", "cpuCores_one": "{{count}} Kern", "cpuCores_other": "{{count}} Kerne", "cpuUsage": "CPU-Auslastung", - "memoryUsage": "Speichernutzung", + "memoryUsage": "Arbeitsspeichernutzung", "diskUsage": "Festplattennutzung", "temperature": "Temperatur", "highestTemperature": "Höchste Temperatur", - "failedToFetchHostConfig": "Fehler beim Abrufen der Hostkonfiguration", + "failedToFetchHostConfig": "Hostkonfiguration konnte nicht abgerufen werden", "serverOffline": "Server offline", "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden", "totpFailed": "TOTP-Verifizierung fehlgeschlagen", - "noneAuthNotSupported": "Host Metrics unterstützt den Authentifizierungstyp „none“ nicht.", + "noneAuthNotSupported": "Host-Metriken unterstützen den Authentifizierungstyp „none“ nicht.", "noHostSelected": "Kein Host ausgewählt", "load": "Laden", "systemInfo": "Systeminformationen", @@ -1983,39 +2030,39 @@ "noInterfacesFound": "Es wurden keine Netzwerkschnittstellen gefunden.", "noProcessesFound": "Keine Prozesse gefunden", "processesTotal": "gesamt", - "processesRunning": "läuft", + "processesRunning": "aktiv", "loginStats": "SSH-Anmeldestatistiken", "noRecentLoginData": "Keine aktuellen Anmeldedaten", - "executingQuickAction": "Ausführung von {{name}}...", + "executingQuickAction": "{{name}} wird ausgeführt...", "quickActionSuccess": "{{name}} erfolgreich abgeschlossen", "quickActionFailed": "{{name}} fehlgeschlagen", "quickActionError": "Ausführung von {{name}} fehlgeschlagen", "ports": { - "title": "Abhöranschlüsse", + "title": "Offene Ports", "protocol": "Protokoll", - "port": "Hafen", + "port": "Port", "address": "Adresse", - "process": "Verfahren", + "process": "Prozess", "search": "Ports durchsuchen...", "allProtocols": "Alle", - "noData": "Keine Daten zu den Listening-Ports" + "noData": "Keine Daten zu offenen Ports" }, "firewall": { "title": "Firewall", "inactive": "Inaktiv", - "policy": "Politik", + "policy": "Richtlinie", "rules": "Regeln", - "noRules": "In dieser Kette gelten keine Regeln.", + "noRules": "Keine Regeln in dieser Kette", "noData": "Keine Firewall-Daten verfügbar", "action": "Aktion", - "protocol": "Proto", - "port": "Hafen", + "protocol": "Protokoll", + "port": "Port", "source": "Quelle", "anywhere": "Überall", "chains": "Ketten" }, - "loadAvg": "Durchschnittliche Auslastung", - "swap": "Tauschen", + "loadAvg": "Durchschnittliche Last", + "swap": "Swap", "architecture": "Architektur", "refresh": "Aktualisieren", "retry": "Wiederholen", @@ -2024,57 +2071,57 @@ "tabLive": "Live", "editModeInstructions": "Ziehen Sie Karten per Drag & Drop, um sie neu anzuordnen, ziehen Sie am unteren Rand, um die Größe anzupassen, und verwenden Sie die Breitenschaltfläche, um die Kartenbreite zu ändern. Fügen Sie unten Karten hinzu oder entfernen Sie sie.", "managers": { - "services": "Dienstleistungen", + "services": "Dienste", "processInspector": "Prozessinspektor", - "logViewer": "Protokollanzeige", + "logViewer": "Log-Anzeige", "cron": "Cron-Jobs", "packages": "Pakete", "ssl": "SSL-Zertifikate", "firewall": "Firewall", "users": "Benutzer und Berechtigungen", - "healthCheck": "Gesundheitschecks", - "diskBreakdown": "Disk-Aufschlüsselung", + "healthCheck": "Zustandsprüfungen", + "diskBreakdown": "Datenträgerbelegung", "systemdTimers": "Timer", - "topMemory": "Nach Speicher sortiert", + "topMemory": "Höchster Arbeitsspeicherverbrauch", "noData": "Keine Daten", "sudoHint": "Um privilegierte Aktionen zu ermöglichen, muss im Host-Editor ein sudo-Passwort für diesen Host festgelegt werden.", "filter": "Filter...", - "start": "Start", + "start": "Starten", "stop": "Stoppen", - "restart": "Neustart", + "restart": "Neu starten", "actionDone": "{{name}} aktualisiert", "actionFailed": "Aktion fehlgeschlagen", - "signalSent": "Signal an PID gesendet {{pid}}", - "killHint": "Klicken: Beenden (SIGTERM). Rechtsklick: Erzwingen des Beendens (SIGKILL).", - "working": "Arbeiten...", + "signalSent": "Signal an PID {{pid}} gesendet", + "killHint": "Klicken: beenden (SIGTERM). Rechtsklick: Beenden erzwingen (SIGKILL).", + "working": "Wird ausgeführt...", "update": "Aktualisieren", "upgradeAll": "Alle aktualisieren", "allUpToDate": "Alles ist auf dem neuesten Stand.", "save": "Speichern", "command": "Befehl", - "enabled": "Ermöglicht", + "enabled": "Aktiviert", "cronSaved": "Crontab aktualisiert", - "clients": "Kunden", - "dryRun": "Trockenübung", + "clients": "Clients", + "dryRun": "Testlauf", "renew": "Erneuern", "noAcmeClient": "Auf diesem Host wurde kein ACME-Client (certbot oder acme.sh) gefunden.", - "addInputRule": "Füge eine INPUT-Regel hinzu", + "addInputRule": "INPUT-Regel hinzufügen", "firewallWarning": "Änderungen werden nur zur Laufzeit wirksam, bis sie gespeichert werden. Achten Sie darauf, sich nicht selbst auszusperren.", - "ruleApplied": "Angewendete Regel", + "ruleApplied": "Regel angewendet", "invalidPort": "Geben Sie einen gültigen Port ein (1-65535)", "newUsername": "Neuer Benutzername", "addUser": "Hinzufügen", "deleteUser": "Benutzer löschen", - "noHealthChecks": "Es sind noch keine Gesundheitsprüfungen konfiguriert.", - "follow": "Folgen", + "noHealthChecks": "Noch keine Zustandsprüfungen konfiguriert.", + "follow": "Fortlaufend anzeigen", "noLogData": "Keine Protokollausgabe.", "tree": "Baum", "enableDisable": "Beim Systemstart aktivieren/deaktivieren", - "grantSudo": "Grant sudo", - "revokeSudo": "sudo widerrufen", - "sslIssueCert": "Ausstellungszertifikat", + "grantSudo": "sudo-Berechtigung erteilen", + "revokeSudo": "sudo-Berechtigung entziehen", + "sslIssueCert": "Zertifikat ausstellen", "sslExpired": "Abgelaufen", - "sslInDays": "in {{days}}d", + "sslInDays": "in {{days}} Tagen", "sslNeedDomain": "Geben Sie mindestens eine Domäne ein", "sslIssued": "Zertifikat ausgestellt", "sslDomainsPlaceholder": "example.com, www.example.com", @@ -2082,39 +2129,39 @@ "sslHttpWebroot": "HTTP (Webroot)", "sslDns": "DNS", "sslDnsProvider": "DNS-Anbieter (z. B. Cloudflare)", - "sslIssueHint": "Die Anmeldeinformationen des DNS-Anbieters müssen bereits auf dem Host konfiguriert sein.", - "sslRevoke": "Widerruf des Zertifikats", + "sslIssueHint": "Die Zugangsdaten des DNS-Anbieters müssen bereits auf dem Host konfiguriert sein.", + "sslRevoke": "Zertifikat widerrufen", "sslRevoked": "Zertifikat widerrufen", "sslRevokeConfirm": "Das Zertifikat \"{{name}}\" widerrufen und entfernen? Dies kann nicht rückgängig gemacht werden.", - "healthRun": "Laufen", + "healthRun": "Ausführen", "healthName": "Name", "healthTarget": "Host / Adresse", - "healthAddCheck": "Kontrollkästchen hinzufügen", - "healthSaved": "Gesundheitschecks eingespart", - "healthMissingFields": "Jede Überprüfung benötigt einen Namen und ein Ziel.", + "healthAddCheck": "Prüfung hinzufügen", + "healthSaved": "Systemprüfungen gespeichert", + "healthMissingFields": "Jede Prüfung benötigt einen Namen und ein Ziel.", "logFile": "Datei", - "logUnit": "Einheit", + "logUnit": "Unit", "logCustomPath": "Benutzerdefinierter Pfad unter /var/log (optional)", - "logGrep": "Filterleitungen...", - "firewallPersist": "Persistenzregeln", - "firewallPersisted": "Firewall-Regeln blieben bestehen", + "logGrep": "Zeilen filtern...", + "firewallPersist": "Regeln dauerhaft speichern", + "firewallPersisted": "Firewall-Regeln dauerhaft gespeichert", "wireguard": "WireGuard", - "tailscale": "Schwanzschuppe", + "tailscale": "Tailscale", "wgNotInstalled": "WireGuard ist auf diesem Host nicht installiert.", "wgNoInterfaces": "Keine WireGuard-Schnittstellen konfiguriert", - "wgInterfaceUp": "Bringen Sie es zur Sprache", - "wgInterfaceDown": "Niederreißen", - "wgBringingUp": "{{name}} wird angezeigt...", - "wgBringingDown": "{{name}} wird heruntergefahren...", - "wgInterfaceUpDone": "{{name}} ist oben", - "wgInterfaceDownDone": "{{name}} ist unten", - "wgListenPort": "Hafen", + "wgInterfaceUp": "Aktivieren", + "wgInterfaceDown": "Deaktivieren", + "wgBringingUp": "{{name}} wird aktiviert...", + "wgBringingDown": "{{name}} wird deaktiviert...", + "wgInterfaceUpDone": "{{name}} ist aktiv", + "wgInterfaceDownDone": "{{name}} ist inaktiv", + "wgListenPort": "Port", "wgPublicKey": "Öffentlicher Schlüssel", "wgEndpoint": "Endpunkt", "wgAllowedIPs": "Zulässige IPs", - "wgLastHandshake": "Letzter Händedruck", + "wgLastHandshake": "Letzter Handshake", "wgHandshakeNever": "Niemals", - "wgTransfer": "Überweisen", + "wgTransfer": "Übertragung", "tsNotInstalled": "Tailscale ist auf diesem Host nicht installiert.", "tsRunning": "Läuft", "tsStopped": "Angehalten", @@ -2122,14 +2169,14 @@ "tsDisable": "Trennen", "tsEnabling": "Verbindung zu Tailscale wird hergestellt...", "tsDisabling": "Verbindung zu Tailscale wird getrennt...", - "tsEnabled": "Schwanzschuppe verbunden", - "tsDisabled": "Heckschuppe abgetrennt", - "tsIPs": "Tailscale IPs", - "tsPeers": "Gleichaltrige", + "tsEnabled": "Tailscale verbunden", + "tsDisabled": "Tailscale getrennt", + "tsIPs": "Tailscale-IPs", + "tsPeers": "Peers", "tsOnline": "Online", "tsOffline": "Offline", - "tsExitNode": "Ausgangsknoten", - "tsExitNodeActive": "Ausgangsknoten aktiv", + "tsExitNode": "Exit-Node", + "tsExitNodeActive": "Exit-Node aktiv", "tsHostname": "Hostname" } }, @@ -2143,16 +2190,16 @@ "hasAccount": "Sie haben bereits ein Konto?", "twoFactorAuth": "Zwei-Faktor-Authentifizierung", "enterCode": "Geben Sie den Bestätigungscode ein.", - "backupCode": "Oder verwenden Sie Backup-Code.", + "backupCode": "Oder einen Backup-Code verwenden", "verifyCode": "Code überprüfen", "redirectingToApp": "Weiterleitung zur App...", "sshAuthenticationRequired": "SSH-Authentifizierung erforderlich", "sshNoKeyboardInteractive": "Tastaturinteraktive Authentifizierung nicht verfügbar", "sshAuthenticationFailed": "Authentifizierung fehlgeschlagen", - "sshAuthenticationTimeout": "Authentifizierungs-Timeout", + "sshAuthenticationTimeout": "Zeitüberschreitung bei der Authentifizierung", "sshNoKeyboardInteractiveDescription": "Der Server unterstützt keine Authentifizierung per Tastatur. Bitte geben Sie Ihr Passwort oder Ihren SSH-Schlüssel ein.", "sshAuthFailedDescription": "Die angegebenen Zugangsdaten waren falsch. Bitte versuchen Sie es erneut mit gültigen Zugangsdaten.", - "sshTimeoutDescription": "Die Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.", + "sshTimeoutDescription": "Beim Authentifizierungsversuch ist eine Zeitüberschreitung aufgetreten. Bitte versuchen Sie es erneut.", "sshProvideCredentialsDescription": "Bitte geben Sie Ihre SSH-Zugangsdaten an, um eine Verbindung zu diesem Server herzustellen.", "sshPasswordDescription": "Geben Sie das Passwort für diese SSH-Verbindung ein.", "sshKeyPasswordDescription": "Falls Ihr SSH-Schlüssel verschlüsselt ist, geben Sie hier die Passphrase ein.", @@ -2162,7 +2209,7 @@ "firstUser": "Erster Benutzer", "firstUserMessage": "Sie sind der erste Benutzer und werden zum Administrator ernannt. Die Administratoreinstellungen finden Sie im Benutzermenü in der Seitenleiste. Sollten Sie der Meinung sein, dass dies ein Fehler ist, überprüfen Sie bitte die Docker-Logs oder erstellen Sie ein GitHub-Issue.", "external": "Extern", - "loginWithExternal": "Anmeldung mit externem Anbieter", + "loginWithExternal": "Mit externem Anbieter anmelden", "loginWithExternalDesc": "Melden Sie sich mit Ihrem konfigurierten externen Identitätsanbieter an.", "externalNotSupportedInElectron": "Die externe Authentifizierung wird in der Electron-App noch nicht unterstützt. Bitte verwenden Sie die Webversion für die OIDC-Anmeldung.", "loginWithProvider": "Anmelden mit {{name}}", @@ -2172,15 +2219,15 @@ "ldapSignIn": "Anmelden", "ldapLoginFailed": "LDAP-Anmeldung fehlgeschlagen", "resetPasswordButton": "Passwort zurücksetzen", - "sendResetCode": "Reset-Code senden", - "resetCodeDesc": "Geben Sie Ihren Benutzernamen ein, um einen Code zum Zurücksetzen Ihres Passworts zu erhalten. Der Code wird in den Docker-Container-Protokollen protokolliert.", + "sendResetCode": "Code zum Zurücksetzen senden", + "resetCodeDesc": "Geben Sie Ihren Benutzernamen ein, um einen Code zum Zurücksetzen Ihres Passworts zu erhalten. Der Code wird in den Docker-Container-Logs ausgegeben.", "resetCode": "Reset-Code", "verifyCodeButton": "Code überprüfen", "enterResetCode": "Geben Sie den 6-stelligen Code aus den Docker-Container-Protokollen für den Benutzer ein:", "newPassword": "Neues Passwort", "confirmNewPassword": "Passwort bestätigen", "enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:", - "signUp": "Melden Sie sich an", + "signUp": "Registrieren", "desktopApp": "Desktop-App", "loggingInToDesktopApp": "Anmeldung bei der Desktop-App", "loadingServer": "Server wird geladen...", @@ -2190,33 +2237,34 @@ "passwordLoginDisabledDesc": "Die Anmeldung mit Passwort ist deaktiviert. Verwenden Sie einen Passkey oder einen externen Authentifizierungsanbieter.", "signInWithPasskey": "Mit Passkey anmelden", "passkeyLoginFailed": "Anmeldung mit Passkey fehlgeschlagen", - "attemptsRemaining": "{{count}} verbleibende Versuche" + "attemptsRemaining": "{{count}} verbleibende Versuche", + "confirmResetDataWipe": "Dieses Konto wurde seit dem Upgrade der Verschlüsselung nicht mehr verwendet. Die gespeicherten Daten können daher ohne das alte Passwort nicht wiederhergestellt werden. Durch das Zurücksetzen werden Hosts, Anmeldeinformationen und Snippets endgültig gelöscht. Fortfahren?" }, "hostKey": { "verifyNewHost": "SSH-Hostschlüssel überprüfen", "keyChangedWarning": "SSH-Hostschlüssel geändert", "firstConnectionTitle": "Erstmalige Verbindung zu diesem Host", "firstConnectionDescription": "Die Authentizität dieses Hosts kann nicht bestätigt werden. Überprüfen Sie, ob der Fingerabdruck Ihren Erwartungen entspricht.", - "keyChangedDescription": "Der Host-Schlüssel für diesen Server hat sich seit Ihrer letzten Verbindung geändert. Dies könnte auf ein Sicherheitsproblem hinweisen.", + "keyChangedDescription": "Der Hostschlüssel für diesen Server hat sich seit Ihrer letzten Verbindung geändert. Dies könnte auf ein Sicherheitsproblem hinweisen.", "previousKey": "Vorheriger Schlüssel", "newFingerprint": "Neuer Fingerabdruck", "fingerprint": "Fingerabdruck", "verifyInstructions": "Wenn Sie diesem Host vertrauen, klicken Sie auf „Akzeptieren“, um fortzufahren und diesen Fingerabdruck für zukünftige Verbindungen zu speichern.", "securityWarning": "Sicherheitswarnung", - "acceptAndContinue": "Akzeptieren & Fortfahren", - "acceptNewKey": "Neuen Schlüssel akzeptieren & fortfahren" + "acceptAndContinue": "Akzeptieren und fortfahren", + "acceptNewKey": "Neuen Schlüssel akzeptieren und fortfahren" }, "errors": { "databaseConnection": "Es konnte keine Verbindung zur Datenbank hergestellt werden.", "unknownError": "Unbekannter Fehler", - "loginFailed": "Fehler bei der Anmeldung", + "loginFailed": "Anmeldung fehlgeschlagen", "failedPasswordReset": "Passwortzurücksetzung konnte nicht initiiert werden", - "failedVerifyCode": "Fehler beim Überprüfen des Reset-Codes", - "failedCompleteReset": "Passwortzurücksetzung fehlgeschlagen.", + "failedVerifyCode": "Zurücksetzungscode konnte nicht überprüft werden", + "failedCompleteReset": "Passwortzurücksetzung konnte nicht abgeschlossen werden", "invalidTotpCode": "Ungültiger TOTP-Code", "failedOidcLogin": "OIDC-Anmeldung konnte nicht gestartet werden.", - "silentSigninOidcUnavailable": "Es wurde eine stille Anmeldung angefordert, aber die OIDC-Anmeldung ist nicht verfügbar.", - "failedUserInfo": "Fehler beim Abrufen der Benutzerinformationen nach dem Login", + "silentSigninOidcUnavailable": "Eine automatische Anmeldung im Hintergrund wurde angefordert, die OIDC-Anmeldung ist jedoch nicht verfügbar.", + "failedUserInfo": "Benutzerinformationen konnten nach der Anmeldung nicht abgerufen werden", "oidcAuthFailed": "OIDC-Authentifizierung fehlgeschlagen", "invalidAuthUrl": "Ungültige Autorisierungs-URL vom Backend empfangen", "requiredField": "Dieses Feld ist erforderlich", @@ -2224,10 +2272,10 @@ "passwordMismatch": "Die Passwörter stimmen nicht überein.", "passwordLoginDisabled": "Die Anmeldung mit Benutzername/Passwort ist derzeit deaktiviert.", "sessionExpired": "Ihre Sitzung ist abgelaufen – bitte melden Sie sich erneut an.", - "totpRateLimited": "Begrenzte Rate: Zu viele TOTP-Verifizierungsversuche. Bitte versuchen Sie es später erneut.", - "totpRateLimitedWithTime": "Ratenbegrenzung: Zu viele TOTP-Verifizierungsversuche. Bitte warten Sie {{time}} Sekunden, bevor Sie es erneut versuchen.", - "resetCodeRateLimited": "Bearbeitungszeit begrenzt: Zu viele Verifizierungsversuche. Bitte versuchen Sie es später erneut.", - "resetCodeRateLimitedWithTime": "Begrenzte Rate: Zu viele Verifizierungsversuche. Bitte warten Sie {{time}} Sekunden, bevor Sie es erneut versuchen.", + "totpRateLimited": "Zu viele TOTP-Bestätigungsversuche. Bitte versuchen Sie es später erneut.", + "totpRateLimitedWithTime": "Zu viele TOTP-Verifizierungsversuche. Bitte warten Sie {{time}} Sekunden, bevor Sie es erneut versuchen.", + "resetCodeRateLimited": "Zu viele Überprüfungsversuche. Bitte versuchen Sie es später erneut.", + "resetCodeRateLimitedWithTime": "Zu viele Bestätigungsversuche. Bitte warten Sie {{time}} Sekunden, bevor Sie es erneut versuchen.", "authTokenSaveFailed": "Speichern des Authentifizierungstokens fehlgeschlagen", "failedToLoadServer": "Server konnte nicht geladen werden" }, @@ -2235,7 +2283,7 @@ "registrationDisabled": "Die Registrierung neuer Konten ist derzeit von einem Administrator deaktiviert. Bitte melden Sie sich an oder kontaktieren Sie einen Administrator.", "userNotAllowed": "Ihr Konto ist nicht zur Registrierung berechtigt. Bitte kontaktieren Sie einen Administrator.", "databaseConnectionFailed": "Verbindung zum Datenbankserver fehlgeschlagen", - "resetCodeSent": "Reset-Code an Docker-Protokolle gesendet", + "resetCodeSent": "Code zum Zurücksetzen in Docker-Logs ausgegeben", "codeVerified": "Code erfolgreich verifiziert", "passwordResetSuccess": "Passwort erfolgreich zurückgesetzt", "loginSuccess": "Anmeldung erfolgreich", @@ -2246,13 +2294,13 @@ "c2sTunnelPresets": "Client-Tunnel-Voreinstellungen", "c2sTunnelPresetsDesc": "Speichern Sie die lokale Tunnelliste dieses Desktop-Clients als benannte Servervoreinstellung oder laden Sie eine Voreinstellung wieder in diesen Client.", "c2sTunnelPresetsUnavailable": "Client-Tunnel-Voreinstellungen sind nur im Desktop-Client verfügbar.", - "c2sPresetName": "Voreingestellter Name", - "c2sPresetNamePlaceholder": "Client-Voreinstellungsname", + "c2sPresetName": "Name der Voreinstellung", + "c2sPresetNamePlaceholder": "Name der Client-Voreinstellung", "c2sPresetToLoad": "Voreinstellung zum Laden", "c2sNoPresetSelected": "Keine Voreinstellung ausgewählt", "c2sNoPresets": "Keine Voreinstellungen gespeichert", "c2sLoadPreset": "Laden", - "c2sCurrentLocalConfig": "{{count}} Lokale Client-Tunnel, die auf diesem Desktop konfiguriert sind.", + "c2sCurrentLocalConfig": "{{count}} lokale Client-Tunnel auf diesem Desktop konfiguriert.", "c2sPresetSyncNote": "Voreinstellungen sind explizite Momentaufnahmen; das Laden einer solchen Voreinstellung ersetzt die lokale Client-Tunnelliste dieses Desktop-Clients.", "c2sPresetSaved": "Client-Tunnel-Voreinstellung gespeichert", "c2sPresetLoaded": "Client-Tunnel-Voreinstellung lokal geladen", @@ -2266,7 +2314,7 @@ "language": "Sprache", "keyPassword": "Schlüsselpasswort", "pastePrivateKey": "Fügen Sie hier Ihren privaten Schlüssel ein...", - "localListenerHost": "127.0.0.1 (lokal hören)", + "localListenerHost": "127.0.0.1 (lokaler Listener)", "localTargetHost": "127.0.0.1 (Ziel auf diesem Computer)", "socksListenerHost": "127.0.0.1 (SOCKS-Listener)", "enterPassword": "Geben Sie Ihr Passwort ein", @@ -2274,12 +2322,12 @@ "defaultEndpointPort": "224" }, "dashboard": { - "title": "Armaturenbrett", + "title": "Dashboard", "loading": "Dashboard wird geladen...", "github": "GitHub", - "support": "Unterstützung", - "discord": "Zwietracht", - "docs": "Dokumente", + "support": "Support", + "discord": "Discord", + "docs": "Dokumentation", "donate": "Spenden", "serverOverview": "Serverübersicht", "version": "Version", @@ -2288,18 +2336,18 @@ "beta": "Beta", "uptime": "Betriebszeit", "database": "Datenbank", - "healthy": "Gesund", + "healthy": "Fehlerfrei", "error": "Fehler", "totalHosts": "Gesamtzahl der Hosts", - "totalTunnels": "Total Tunnels", - "totalCredentials": "Gesamte Qualifikationsnachweise", - "recentActivity": "Aktuelle Aktivitäten", + "totalTunnels": "Tunnel insgesamt", + "totalCredentials": "Gesamtzahl der Zugangsdaten", + "recentActivity": "Letzte Aktivitäten", "reset": "Zurücksetzen", "loadingRecentActivity": "Aktuelle Aktivitäten werden geladen...", - "noRecentActivity": "Keine aktuelle Aktivität", + "noRecentActivity": "Keine kürzlichen Aktivitäten", "quickActions": "Schnellaktionen", "addHost": "Host hinzufügen", - "addCredential": "Anmeldeinformationen hinzufügen", + "addCredential": "Zugangsdaten hinzufügen", "adminSettings": "Administratoreinstellungen", "userProfile": "Benutzerprofil", "serverStats": "Serverstatistiken", @@ -2317,25 +2365,32 @@ "networkGraph": "Netzwerkdiagramm", "quickActionsCard": "Schnellaktionen", "serverStatsCard": "Serverstatistiken", - "panelMain": "Hauptsächlich", - "panelSide": "Seite", + "panelMain": "Hauptbereich", + "panelSide": "Seitlich", "justNow": "soeben", "serviceLinks": "Service-Links", "homepagePreview": "Startseitenvorschau" }, + "donation": { + "title": "Gefällt Ihnen Termix?", + "body": "Termix ist kostenlos und Open Source und wird von einem Zweierteam in unserer Freizeit entwickelt und gepflegt. Wenn es ein kommerzielles Tool ersetzt, für das Sie sonst bezahlen müssten, hilft eine Spende, die Hostingkosten zu decken und die Weiterentwicklung zu sichern. Aktuell sind Spenden nur in Kryptowährung möglich.", + "milestones": "Spenden tragen auch dazu bei, die Zeit für Recherchen und das Erlernen der notwendigen Funktionen wie SAML- und Kubernetes-Unterstützung zu finanzieren. Den Fortschritt können Sie auf der Spendenseite verfolgen.", + "cta": "Spenden", + "dismiss": "Vielleicht später" + }, "dashboardTab": { "stable": "STABIL", - "hostsOnline": "Hosts Online", + "hostsOnline": "Hosts online", "activeTunnels": "Aktive Tunnel", "registerNewServer": "Neuen Server registrieren", - "storeSshKeysOrPasswords": "Speichern Sie SSH-Schlüssel oder Passwörter", + "storeSshKeysOrPasswords": "SSH-Schlüssel oder Passwörter speichern", "manageUsersAndRoles": "Benutzer und Rollen verwalten", - "manageYourAccount": "Verwalten Sie Ihr Konto", + "manageYourAccount": "Ihr Konto verwalten", "hostStatus": "Hoststatus", "noHostsConfigured": "Keine Hosts konfiguriert", "online": "ONLINE", "offline": "OFFLINE", - "checking": "ÜBERPRÜFUNG", + "checking": "WIRD GEPRÜFT", "onlineLower": "Online", "nodes": "{{count}} Knoten", "add": "Hinzufügen:", @@ -2343,17 +2398,17 @@ "done": "Erledigt", "editModeInstructions": "Karten per Drag & Drop neu anordnen · Spaltentrenner per Drag & Drop ziehen, um die Spaltenbreite anzupassen · Unterkante einer Karte per Drag & Drop ziehen, um ihre Höhe zu ändern · Zum Entfernen in den Papierkorb klicken", "empty": "Leer", - "clear": "Klar", + "clear": "Leeren", "serviceLinksTitle": "Service-Links", "serviceLinksEmpty": "Noch keine Service-Links.", - "serviceLinksAddLabel": "Etikett", + "serviceLinksAddLabel": "Bezeichnung", "serviceLinksAddUrl": "URL", "serviceLinksAdd": "Hinzufügen", "serviceLinksLabelPlaceholder": "Mein Service", "serviceLinksUrlPlaceholder": "http://192.168.1.10:8080", "serviceLinksInvalidUrl": "Geben Sie eine gültige Webadresse ein.", - "serviceLinksAddFailed": "Dienstlink konnte nicht hinzugefügt werden", - "disk": "Scheibe", + "serviceLinksAddFailed": "Service-Link konnte nicht hinzugefügt werden", + "disk": "Datenträger", "viewServerDetails": "Serverdetails anzeigen" }, "sessionLogs": { @@ -2365,8 +2420,8 @@ "downloadLog": "Herunterladen", "deleteLog": "Löschen", "confirmDelete": "Dieses Sitzungsprotokoll löschen?", - "confirmDeleteDesc": "Diese Handlung kann nicht rückgängig gemacht werden.", - "copyContent": "Kopie", + "confirmDeleteDesc": "Diese Aktion kann nicht rückgängig gemacht werden.", + "copyContent": "Kopieren", "copied": "Kopiert!", "loadError": "Sitzungsprotokolle konnten nicht geladen werden", "deleteError": "Sitzungsprotokoll konnte nicht gelöscht werden", @@ -2377,27 +2432,27 @@ "addGroup": "Gruppe hinzufügen", "addLink": "Link hinzufügen", "zoomIn": "Vergrößern", - "zoomOut": "Herauszoomen", + "zoomOut": "Verkleinern", "resetView": "Ansicht zurücksetzen", "selectHost": "Host auswählen", - "chooseHost": "Wählen Sie einen Gastgeber...", - "parentGroup": "Elterngruppe", + "chooseHost": "Host auswählen...", + "parentGroup": "Übergeordnete Gruppe", "noGroup": "Keine Gruppe", "groupName": "Gruppenname", "color": "Farbe", "source": "Quelle", "target": "Ziel", - "moveToGroup": "Zur Gruppe wechseln", + "moveToGroup": "In Gruppe verschieben", "selectGroup": "Gruppe auswählen...", "addConnection": "Verbindung hinzufügen", - "hostDetails": "Gastgeberdetails", + "hostDetails": "Hostdetails", "removeFromGroup": "Aus Gruppe entfernen", "addHostHere": "Hier Host hinzufügen", "editGroup": "Gruppe bearbeiten", "delete": "Löschen", "add": "Hinzufügen", "create": "Erstellen", - "move": "Bewegen", + "move": "Verschieben", "connect": "Verbinden", "createGroup": "Gruppe erstellen", "selectSourcePlaceholder": "Quelle auswählen...", @@ -2411,7 +2466,7 @@ "status": "Status", "failedToAddNode": "Knoten konnte nicht hinzugefügt werden", "sourceDifferentFromTarget": "Quelle und Ziel müssen unterschiedlich sein.", - "exportJSON": "JSON exportieren", + "exportJSON": "Als JSON exportieren", "importJSON": "JSON importieren", "terminal": "Terminal", "fileManager": "Dateimanager", @@ -2423,7 +2478,7 @@ }, "docker": { "notEnabled": "Docker ist für diesen Host nicht aktiviert.", - "validating": "Docker wird validiert...", + "validating": "Docker wird überprüft...", "connecting": "Verbindung wird hergestellt...", "error": "Fehler", "version": "Docker {{version}}", @@ -2435,41 +2490,41 @@ "containerRestarted": "Container {{name}} wurde neu gestartet", "failedToRestartContainer": "Neustart des Containers {{name}} fehlgeschlagen", "containerPaused": "Container {{name}} pausiert", - "containerUnpaused": "Container {{name}} nicht pausiert", + "containerUnpaused": "Container {{name}} fortgesetzt", "failedToTogglePauseContainer": "Fehler beim Umschalten des Pausenstatus für Container {{name}}", "containerRemoved": "Container {{name}} entfernt", "failedToRemoveContainer": "Container {{name}} konnte nicht entfernt werden.", - "image": "Bild", - "ports": "Häfen", - "noPorts": "Keine Anschlüsse", - "start": "Start", + "image": "Image", + "ports": "Ports", + "noPorts": "Keine Ports", + "start": "Starten", "confirmRemoveContainer": "Möchten Sie den Container '{{name}}' wirklich entfernen? Diese Aktion kann nicht rückgängig gemacht werden.", "runningContainerWarning": "Warnung: Dieser Container wird derzeit ausgeführt. Durch das Entfernen wird der Container zuerst gestoppt.", - "loadingContainers": "Container werden verladen...", - "manager": "Docker Manager", + "loadingContainers": "Container werden geladen...", + "manager": "Docker-Manager", "autoRefresh": "Automatische Aktualisierung", "timestamps": "Zeitstempel", - "lines": "Linien", - "filterLogs": "Filterprotokolle...", + "lines": "Zeilen", + "filterLogs": "Protokolle filtern...", "refresh": "Aktualisieren", "download": "Herunterladen", - "clear": "Klar", + "clear": "Leeren", "logsDownloaded": "Protokolle erfolgreich heruntergeladen", "last50": "Die letzten 50", "last100": "Die letzten 100", "last500": "Die letzten 500", "last1000": "Die letzten 1000", "allLogs": "Alle Protokolle", - "noLogsMatching": "Keine Protokolleinträge, die \"{{query}} \" entsprechen", + "noLogsMatching": "Keine Protokolleinträge entsprechen „{{query}}“", "noLogsAvailable": "Keine Protokolle verfügbar", - "noContainersFound": "Keine Behälter gefunden", + "noContainersFound": "Keine Container gefunden", "noContainersFoundHint": "Auf diesem Host sind keine Docker-Container verfügbar.", "searchPlaceholder": "Container durchsuchen...", - "allStatuses": "Alle Status", + "allStatuses": "Alle Statuswerte", "stateRunning": "Läuft", - "statePaused": "Angehalten", + "statePaused": "Pausiert", "stateExited": "Beendet", - "stateRestarting": "Neustart", + "stateRestarting": "Wird neu gestartet", "noContainersMatchFilters": "Keine Container entsprechen Ihren Filtern.", "noContainersMatchFiltersHint": "Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen.", "failedToFetchStats": "Fehler beim Abrufen der Containerstatistiken", @@ -2480,7 +2535,7 @@ "noStatsAvailable": "Keine Statistiken verfügbar", "cpuUsage": "CPU-Auslastung", "current": "Aktuell", - "memoryUsage": "Speichernutzung", + "memoryUsage": "Arbeitsspeicherauslastung", "networkIo": "Netzwerk-E/A", "input": "Eingang", "output": "Ausgabe", @@ -2490,7 +2545,7 @@ "pids": "PIDs", "containerInformation": "Containerinformationen", "name": "Name", - "id": "AUSWEIS", + "id": "ID", "state": "Zustand", "containerMustBeRunning": "Der Container muss ausgeführt werden, um auf die Konsole zugreifen zu können.", "verificationCodePrompt": "Geben Sie den Bestätigungscode ein.", @@ -2505,7 +2560,7 @@ "selectShell": "Wählen Sie die Shell aus.", "bash": "Bash", "sh": "sh", - "ash": "Asche", + "ash": "ash", "connect": "Verbinden", "disconnect": "Trennen", "notConnected": "Nicht verbunden", @@ -2523,8 +2578,8 @@ "sectionOidc": "OIDC", "sectionSso": "SSO-Anbieter", "ssoAddProvider": "Anbieter hinzufügen", - "ssoDocsLink": "Dokumente ansehen", - "ssoProviderDocsLink": "Dokumente ansehen", + "ssoDocsLink": "Dokumentation anzeigen", + "ssoProviderDocsLink": "Dokumentation anzeigen", "ssoNoProviders": "Es sind keine SSO-Anbieter konfiguriert.", "ssoProviderName": "Anzeigename", "ssoProviderType": "Anbietertyp", @@ -2534,21 +2589,21 @@ "ssoTypeLdap": "LDAP", "ssoTypeGithub": "GitHub", "ssoTypeGoogle": "Google", - "ssoEnabled": "Ermöglicht", + "ssoEnabled": "Aktiviert", "ssoDisabled": "Deaktiviert", "ssoSaveProvider": "Anbieter speichern", - "ssoTestConnection": "Testverbindung", + "ssoTestConnection": "Verbindung testen", "ssoEditProvider": "Anbieter bearbeiten", "ldapHost": "LDAP-Host", - "ldapPort": "Hafen", - "ldapUseTls": "Verwenden Sie TLS (LDAPS).", - "ldapBindDn": "DN binden", - "ldapBindPassword": "Passwort binden", + "ldapPort": "Port", + "ldapUseTls": "TLS (LDAPS) verwenden", + "ldapBindDn": "Bind-DN", + "ldapBindPassword": "Bind-Passwort", "ldapUserSearchBase": "Benutzer-Suchbasis", "ldapUserSearchFilter": "Benutzer-Suchfilter", "ldapUsernameAttr": "Benutzername-Attribut", "ldapDisplayNameAttr": "Anzeigename-Attribut", - "ldapGroupSearchBase": "Gruppensuche-Basis", + "ldapGroupSearchBase": "Gruppensuchbasis", "ldapAdminGroup": "Administratorgruppe", "ldapAllowedUsers": "Zugelassene Benutzer", "sectionUsers": "Benutzer", @@ -2562,25 +2617,25 @@ "sslDocsLink": "SSL-Dokumentation ansehen", "sslDomain": "Domain", "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Der öffentliche Domänenname für das Zertifikat.", + "sslDomainDesc": "Der öffentliche Domainname für das Zertifikat.", "sslEmail": "E-Mail", "sslEmailPlaceholder": "admin@example.com", "sslEmailDesc": "Kontakt-E-Mail-Adresse für Let's Encrypt-Benachrichtigungen und Konto.", - "sslChallengeType": "Herausforderungstyp", - "sslChallengeTypeDesc": "Wie man Let's Encrypt den Besitz einer Domain nachweist.", + "sslChallengeType": "Challenge-Typ", + "sslChallengeTypeDesc": "Methode zum Nachweis des Domainbesitzes gegenüber Let’s Encrypt.", "sslChallengeHttp": "HTTP (Webroot) – benötigt Port 80, der aus dem Internet erreichbar sein muss.", "sslChallengeDns": "DNS (Cloudflare) – erfordert ein Cloudflare-API-Token", "sslCloudflareToken": "Cloudflare API-Token", "sslCloudflareTokenPlaceholder": "Token eingeben...", - "sslCloudflareTokenDesc": "Bereichstoken mit der Berechtigung Zone:DNS:Edit für Ihre Domain.", + "sslCloudflareTokenDesc": "Token mit der Berechtigung Zone:DNS:Edit, das auf Ihre Domain beschränkt ist.", "sslCertStatus": "Zertifikatsstatus", "sslCertStatusNone": "Kein Zertifikat", "sslCertStatusValid": "Gültig", "sslCertStatusExpiring": "Läuft bald ab", "sslCertStatusExpired": "Abgelaufen", - "sslCertExpiresAt": "Läuft ab {{date}}", - "sslLastIssued": "Zuletzt ausgegeben {{date}}", - "sslRequestCert": "Ausstellung/Erneuerung des Zertifikats", + "sslCertExpiresAt": "Läuft am {{date}} ab", + "sslLastIssued": "Zuletzt ausgestellt: {{date}}", + "sslRequestCert": "Zertifikat ausstellen/erneuern", "sslRequestCertLoading": "Zertifikat wird angefordert...", "sslRequestCertSuccess": "Zertifikat erfolgreich ausgestellt und installiert", "sslRequestCertFailed": "Zertifikatsanforderung fehlgeschlagen", @@ -2589,11 +2644,11 @@ "sslSaveFailed": "SSL-Einstellungen konnten nicht gespeichert werden", "sslRequiresDomain": "Domain und E-Mail-Adresse sind erforderlich.", "sslInfoNote": "Nach der Ausstellung des Zertifikats aktivieren Sie SSL in Ihren Umgebungsvariablen (ENABLE_SSL=true) und starten Sie Termix neu.", - "auditLogTotal": "{{total}} Gesamtzahl der Einträge", - "auditLogEmpty": "Es wurden keine Einträge im Audit-Log gefunden.", + "auditLogTotal": "{{total}} Einträge insgesamt", + "auditLogEmpty": "Keine Einträge im Audit-Protokoll gefunden", "auditLogSuccess": "Erfolg", "auditLogFailed": "Fehlgeschlagen", - "auditLogClearFilters": "Filter löschen", + "auditLogClearFilters": "Filter zurücksetzen", "auditLogPage": "Seite {{page}} von {{totalPages}} ({{total}} insgesamt)", "auditLogIp": "IP", "auditLogResourceId": "Ressourcen-ID", @@ -2601,56 +2656,56 @@ "auditLogFilterAction": "Aktion", "auditLogFilterResourceType": "Ressourcentyp", "auditLogFilterStatus": "Status", - "auditLogFilterFrom": "Aus", - "auditLogFilterTo": "Zu", + "auditLogFilterFrom": "Von", + "auditLogFilterTo": "Bis", "auditLogFilterAll": "Alle", "allowRegistration": "Benutzerregistrierung zulassen", - "allowRegistrationDesc": "Neue Benutzer sollen sich selbst mit einem Benutzernamen und einem Passwort registrieren können.", + "allowRegistrationDesc": "Neuen Benutzern die Selbstregistrierung mit Benutzername und Passwort erlauben", "allowPasswordLogin": "Passwortanmeldung zulassen", - "allowPasswordLoginDesc": "Benutzername/Passwort-Anmeldung", + "allowPasswordLoginDesc": "Anmeldung mit Benutzername/Passwort", "oidcAutoProvision": "OIDC-Auto-Provisionierung", - "oidcAutoProvisionDesc": "Automatische Kontoerstellung für OIDC/SSO-Benutzer beim ersten Login (unabhängig von der Registrierungsoption)", - "oidcSilentLoginDefault": "Standardmäßig stille OIDC-Anmeldung", - "oidcSilentLoginDefaultDesc": "Automatische Weiterleitung zum OIDC-Login bei jedem Besuch, das Anmeldeformular wird dabei komplett übersprungen.", + "oidcAutoProvisionDesc": "Konten für OIDC-/SSO-Benutzer bei der ersten Anmeldung automatisch erstellen (unabhängig von der Registrierungsoption)", + "oidcSilentLoginDefault": "Standardmäßig automatische OIDC-Anmeldung im Hintergrund", + "oidcSilentLoginDefaultDesc": "Bei jedem Besuch automatisch zur OIDC-Anmeldung weiterleiten und das Anmeldeformular vollständig überspringen", "allowPasswordReset": "Passwort zurücksetzen erlauben", - "allowPasswordResetDesc": "Code über Docker-Logs zurücksetzen", + "allowPasswordResetDesc": "Code zum Zurücksetzen über Docker-Logs", "commandHistoryEnabled": "Befehlshistorie", "commandHistoryEnabledDesc": "Befehlsverlaufsaufzeichnung zulassen. Wenn diese Option deaktiviert ist, wird der Verlauf unabhängig von den hostspezifischen Einstellungen nicht gespeichert.", "updateCommandHistoryFailed": "Fehler beim Aktualisieren der Befehlsverlaufseinstellung", "sessionTimeout": "Sitzungstimeout", "hours": "Std.", "sessionTimeoutRange": "Mindestens 1 Stunde · Maximal 720 Stunden", - "monitoringDefaults": "Überwachungsstandards", + "monitoringDefaults": "Standardwerte für die Überwachung", "statusCheck": "Statusprüfung", - "metrics": "Kennzahlen", - "sec": "Sekunde", + "metrics": "Metriken", + "sec": "Sek.", "logLevel": "Protokollierungsstufe", "enableGuacamole": "Guacamole aktivieren", "enableGuacamoleDesc": "RDP/VNC-Fernzugriff", - "enableGuacamoleDocsLink": "Dokumente ansehen", + "enableGuacamoleDocsLink": "Dokumentation anzeigen", "guacdUrl": "guacd-URL", "tailscaleApiKey": "Tailscale API-Schlüssel", "tailscaleApiKeyDescription": "Wird zur Geräteerkennung im Host-Editor verwendet. Generieren Sie einen Schlüssel unter tailscale.com/admin/settings/keys.", - "tailscaleApiKeyDocsLink": "Dokumente ansehen", + "tailscaleApiKeyDocsLink": "Dokumentation anzeigen", "oidcDescription": "Konfigurieren Sie OpenID Connect für SSO. Felder mit * sind Pflichtfelder.", - "oidcDocsLink": "Dokumente ansehen", + "oidcDocsLink": "Dokumentation anzeigen", "oidcClientId": "Client-ID", - "oidcClientSecret": "Kundengeheimnis", + "oidcClientSecret": "Client-Secret", "oidcAuthUrl": "Autorisierungs-URL", "oidcIssuerUrl": "Aussteller-URL", "oidcTokenUrl": "Token-URL", "oidcUserIdentifier": "Benutzeridentifikationspfad", - "oidcDisplayName": "Anzeigename Pfad", - "oidcScopes": "Zielfernrohre", + "oidcDisplayName": "Pfad zum Anzeigenamen", + "oidcScopes": "Scopes", "oidcUserinfoUrl": "Benutzerinfo-URL überschreiben", "oidcAllowedUsers": "Zugelassene Benutzer", - "oidcAllowedUsersDesc": "Eine E-Mail pro Zeile. Lassen Sie das Feld leer, um alle E-Mails zuzulassen.", + "oidcAllowedUsersDesc": "Eine E-Mail-Adresse pro Zeile. Lassen Sie das Feld leer, um alle Benutzer zuzulassen.", "oidcAdminGroup": "Administratorgruppe", "oidcAdminGroupDesc": "Benutzer dieser Gruppe erhalten Administratorrechte. Lassen Sie das Feld leer, um die Gruppensynchronisierung zu deaktivieren.", - "oidcGroupClaim": "Gruppenanspruch", - "oidcGroupClaimDesc": "Optional. Der Pfad des Anspruchs, der die Gruppen des Benutzers enthält. Standardmäßig werden Gruppen, Rollen und dann Gruppen verwendet. Verwenden Sie dies für Anbieter mit einem benutzerdefinierten Anspruch (z. B. Zitadel).", + "oidcGroupClaim": "Gruppen-Claim", + "oidcGroupClaimDesc": "Optional. Der Claim-Pfad, der die Gruppen des Benutzers enthält. Standardmäßig werden nacheinander groups, roles und group verwendet. Nutzen Sie diese Option für Anbieter mit einem benutzerdefinierten Claim (z. B. Zitadel).", "oidcCaCert": "Benutzerdefiniertes CA-Zertifikat", - "oidcCaCertDesc": "Optional. PEM-kodiertes CA-Zertifikat für OIDC-Anbieter, die eine private oder selbstsignierte CA verwenden. Lassen Sie dieses Feld leer, um den System-Truststore zu verwenden.", + "oidcCaCertDesc": "Optional. PEM-kodiertes CA-Zertifikat für OIDC-Anbieter mit einer privaten oder selbstsignierten CA. Lassen Sie das Feld leer, um den Vertrauensspeicher des Systems zu verwenden.", "removeOidc": "Entfernen", "usersCount": "{{count}} Benutzer", "createUser": "Erstellen", @@ -2661,21 +2716,21 @@ "rolesCount": "{{count}} Rollen", "createRole": "Erstellen", "creating": "Wird erstellt...", - "exportDatabase": "Exportdatenbank", - "exportDatabaseDesc": "Laden Sie eine Sicherungskopie aller Hosts, Anmeldeinformationen und Einstellungen herunter.", - "export": "Export", - "exporting": "Exportieren...", + "exportDatabase": "Datenbank exportieren", + "exportDatabaseDesc": "Laden Sie eine Sicherung aller Hosts, Zugangsdaten und Einstellungen herunter.", + "export": "Exportieren", + "exporting": "Wird exportiert...", "importDatabase": "Datenbank importieren", "importDatabaseDesc": "Wiederherstellung aus einer .sqlite-Sicherungsdatei", "importDatabaseSelected": "Ausgewählt: {{name}}", "selectFile": "Datei auswählen", "changeFile": "Ändern", - "import": "Import", - "importing": "Importiert...", - "apiKeysCount": "{{count}} Tasten", - "apiKeysDocsLink": "Dokumente ansehen", + "import": "Importieren", + "importing": "Wird importiert...", + "apiKeysCount": "{{count}} Schlüssel", + "apiKeysDocsLink": "Dokumentation anzeigen", "newApiKey": "Neuer API-Schlüssel", - "apiKeyCreatedWarning": "Schlüssel erstellt – kopieren Sie ihn jetzt, er wird nicht erneut angezeigt.", + "apiKeyCreatedWarning": "Schlüssel erstellt – kopieren Sie ihn jetzt; er wird nicht erneut angezeigt.", "apiKeyName": "Name", "apiKeyUser": "Benutzer", "apiKeySelectUser": "Wählen Sie einen Benutzer aus...", @@ -2690,11 +2745,11 @@ "adminStatusRegularUser": "Normaler Benutzer", "adminBadge": "ADMIN", "systemBadge": "SYSTEM", - "customBadge": "BRAUCH", + "customBadge": "INDIVIDUELL", "youBadge": "DU", "sessionsActive": "{{count}} aktiv", "sessionActive": "Aktiv: {{time}}", - "sessionExpires": "Exp: {{time}}", + "sessionExpires": "Läuft ab: {{time}}", "revokeAll": "Alle", "revokeAllSessionsSuccess": "Alle Sitzungen für Benutzer widerrufen", "revokeAllSessionsFailed": "Sitzungen konnten nicht widerrufen werden", @@ -2708,11 +2763,11 @@ "userAdminAccessDesc": "Vollständiger Zugriff auf alle Administratoreinstellungen", "userRoles": "Rollen", "revokeAllUserSessions": "Alle Sitzungen widerrufen", - "revokeAllUserSessionsDesc": "Erzwinge die erneute Anmeldung auf allen Geräten", + "revokeAllUserSessionsDesc": "Erneute Anmeldung auf allen Geräten erzwingen", "revoke": "Widerrufen", "deleteUserWarning": "Das Löschen dieses Benutzers ist endgültig.", - "deleteUser": "Löschen {{username}}", - "deleting": "Löschen...", + "deleteUser": "„{{username}}“ löschen", + "deleting": "Wird gelöscht...", "deleteUserFailed": "Benutzer konnte nicht gelöscht werden", "deleteUserSuccess": "Benutzer \"{{username}}\" gelöscht", "deleteRoleSuccess": "Rolle \"{{name}}\" gelöscht", @@ -2735,29 +2790,29 @@ "editUserAdminStatus": "Administratorstatus", "editUserUserId": "Benutzer-ID", "linkAccountTitle": "Konten verknüpfen", - "linkAccountDesc": "Das OIDC-Konto {{username}} wird mit einem bestehenden lokalen Konto zusammengeführt.", - "linkAccountDescLocal": "Verknüpfen Sie ein lokales Konto {{username}} mit einem bestehenden OIDC-only-Konto.", + "linkAccountDesc": "OIDC-Konto {{username}} mit einem vorhandenen lokalen Konto zusammenführen.", + "linkAccountDescLocal": "Verknüpfen Sie das lokale Konto {{username}} mit einem vorhandenen Konto mit ausschließlicher OIDC-Anmeldung.", "linkAccountWarningTitle": "Dies wird:", - "linkAccountEffect1": "Löschen Sie das OIDC-only-Konto.", - "linkAccountEffect2": "Fügen Sie dem Zielkonto die OIDC-Anmeldung hinzu.", + "linkAccountEffect1": "Das reine OIDC-Konto löschen", + "linkAccountEffect2": "OIDC-Anmeldung zum Zielkonto hinzufügen", "linkAccountEffect3": "Sowohl OIDC- als auch Passwortanmeldung zulassen", "linkAccountTargetUsername": "Benutzername des lokalen Kontos", "linkAccountTargetPlaceholder": "Geben Sie den Benutzernamen des lokalen Kontos ein, das verknüpft werden soll.", "linkAccountOidcUsername": "OIDC-Kontobenutzername", - "linkAccountOidcPlaceholder": "Geben Sie den Benutzernamen des OIDC-only-Kontos ein, das zusammengeführt werden soll.", - "linkAccountOidcNotFound": "Es wurde kein OIDC-only-Konto mit diesem Benutzernamen gefunden.", + "linkAccountOidcPlaceholder": "Geben Sie den Benutzernamen des Kontos mit ausschließlicher OIDC-Anmeldung ein, das zusammengeführt werden soll.", + "linkAccountOidcNotFound": "Kein reines OIDC-Konto mit diesem Benutzernamen gefunden", "linkAccounts": "Konten verknüpfen", "linkAccountSuccess": "Konten erfolgreich verknüpft", "linkAccountFailed": "Konten konnten nicht verknüpft werden", - "linkAccountInProgress": "Verknüpfung...", + "linkAccountInProgress": "Wird verknüpft...", "unlinkAccountTitle": "OIDC entkoppeln", - "unlinkAccountDesc": "Entfernen Sie die OIDC-Authentifizierung von {{username}}. Sie können sich dann nur noch mit ihrem Passwort anmelden.", + "unlinkAccountDesc": "Entfernen Sie die OIDC-Authentifizierung von {{username}}. Der Benutzer kann sich danach nur noch mit seinem Passwort anmelden.", "unlinkAccountWarning": "Dadurch wird die OIDC-Anmeldung für dieses Konto entfernt. Der Benutzer muss ein Passwort festgelegt haben, um sich weiterhin anmelden zu können.", "unlinkAccount": "OIDC entkoppeln", "unlinkAccountInProgress": "Verknüpfung wird aufgehoben...", "unlinkAccountSuccess": "OIDC erfolgreich getrennt", "unlinkAccountFailed": "OIDC konnte nicht getrennt werden", - "saving": "Speichern...", + "saving": "Wird gespeichert...", "updateRegistrationFailed": "Registrierungseinstellungen konnten nicht aktualisiert werden", "updatePasswordLoginFailed": "Fehler beim Aktualisieren der Passwort-Anmeldeeinstellungen", "cannotDisablePasswordLoginWithTotp": "Die Passwortanmeldung kann nicht deaktiviert werden, solange die Zwei-Faktor-Authentifizierung (2FA) für einen oder mehrere Benutzer aktiviert ist. Deaktivieren Sie daher zuerst die 2FA.", @@ -2766,11 +2821,11 @@ "updatePasswordResetFailed": "Fehler beim Aktualisieren der Einstellungen zum Zurücksetzen des Passworts", "sessionTimeoutRange2": "Die Sitzungsdauer muss zwischen 1 und 720 Stunden liegen.", "sessionTimeoutSaved": "Sitzungstimeout gespeichert", - "sessionTimeoutSaveFailed": "Sitzung konnte nicht gespeichert werden (Timeout)", + "sessionTimeoutSaveFailed": "Sitzungstimeout konnte nicht gespeichert werden", "monitoringIntervalInvalid": "Ungültige Intervallwerte", "monitoringSaved": "Überwachungseinstellungen gespeichert", "monitoringSaveFailed": "Die Überwachungseinstellungen konnten nicht gespeichert werden.", - "metricsHistoryRetention": "Speicherung des Metrikverlaufs", + "metricsHistoryRetention": "Aufbewahrungsdauer des Metrikverlaufs", "metricsHistoryRetentionRange": "1 bis 90 Tage", "days": "Tage", "guacamoleSaved": "Guacamole-Einstellungen gespeichert", @@ -2790,11 +2845,76 @@ "updateAdminStatusFailed": "Fehler beim Aktualisieren des Administratorstatus", "allSessionsRevoked": "Alle Sitzungen widerrufen", "revokeSessionsFailed": "Sitzungen konnten nicht widerrufen werden", + "manageUserData": "Benutzerdaten verwalten", + "backToUsers": "Zurück zu den Benutzern", + "manageTabAccount": "Konto", + "manageTabHosts": "Gastgeber", + "manageTabCredentials": "Anmeldeinformationen", + "manageTabSnippets": "Ausschnitte", + "manageTabSessions": "Sitzungen", + "manageTabDanger": "Gefahr", + "manageEditorBack": "Zurück zu den Daten von {{username}}", + "dataLockedBadge": "GESPERRT", + "dataLockedNotice": "Die Daten von {{username}}bleiben gesperrt, bis sie sich das nächste Mal anmelden. Ihre Hosts, Anmeldeinformationen und Snippets können bis dahin weder angezeigt noch bearbeitet werden.", + "resetPasswordTitle": "Passwort zurücksetzen", + "resetPasswordOidcOnly": "Dieser Benutzer meldet sich über einen externen Anbieter an und hat kein Passwort.", + "resetPasswordPlaceholder": "Neues Passwort", + "resetPasswordBtn": "Zurücksetzen", + "resetPasswordWorking": "Zurücksetzen...", + "resetPasswordSuccess": "Passwort zurücksetzen", + "resetPasswordSuccessWiped": "Passwort zurückgesetzt. Die verschlüsselten Daten des Benutzers wurden gelöscht.", + "resetPasswordFailed": "Passwort konnte nicht zurückgesetzt werden", + "resetPasswordConfirmWipe": "{{username}} hat sich seit dem Verschlüsselungs-Upgrade nicht mehr angemeldet, daher können seine Daten nicht wiederhergestellt werden. Durch das Zurücksetzen werden seine Hosts, Anmeldeinformationen und Snippets gelöscht. Fortfahren?", + "totpSectionTitle": "Zwei-Faktor-Authentifizierung", + "totpStatusEnabled": "TOTP ist für diesen Benutzer aktiviert.", + "totpStatusDisabled": "TOTP ist für diesen Benutzer nicht aktiviert.", + "disableTotp": "Deaktivieren", + "disableTotpConfirm": "Die Zwei-Faktor-Authentifizierung für {{username}}deaktivieren? Sie können sich dann nur mit ihrem Passwort anmelden.", + "totpDisabledSuccess": "TOTP deaktiviert", + "totpDisableFailed": "TOTP konnte nicht deaktiviert werden.", + "manageApiKeys": "API-Schlüssel", + "apiKeyNamePlaceholder": "Schlüsselname", + "apiKeyCopyNotice": "Kopieren Sie diesen Schlüssel jetzt, er wird nicht wieder angezeigt.", + "noApiKeysForUser": "Keine API-Schlüssel", + "apiKeyDeleteFailed": "API-Schlüssel konnte nicht gelöscht werden", + "exportUserData": "Datenexport", + "exportUserDataDesc": "Laden Sie die Hosts, Anmeldeinformationen und Dateimanagerdaten dieses Benutzers als JSON herunter. Geheimnisse werden entschlüsselt.", + "exportUserDataSuccess": "Benutzerdaten exportiert", + "exportUserDataFailed": "Fehler beim Exportieren der Benutzerdaten", + "hostsCount": "{{count}} Hosts", + "addHostForUser": "Host hinzufügen", + "noHostsForUser": "Dieser Benutzer hat keine Hosts.", + "connectToHost": "Verbinden", + "deleteHostConfirm": "Host \"{{name}}\" zu {{username}} löschen?", + "hostDeletedSuccess": "Host gelöscht", + "hostDeleteFailed": "Host konnte nicht gelöscht werden", + "credentialsCount": "{{count}} Anmeldeinformationen", + "addCredentialForUser": "Anmeldeinformationen hinzufügen", + "noCredentialsForUser": "Dieser Benutzer besitzt keine Anmeldeinformationen.", + "deleteCredentialConfirm": "Anmeldeinformationen \"{{name}}\" zu {{username}} löschen?", + "credentialDeletedSuccess": "Anmeldeinformationen gelöscht", + "credentialDeleteFailed": "Anmeldeinformationen konnten nicht gelöscht werden", + "snippetsCount": "{{count}} Ausschnitte", + "addSnippetForUser": "Code-Snippet hinzufügen", + "noSnippetsForUser": "Dieser Benutzer hat keine Snippets.", + "snippetRequiredFields": "Name und Inhalt des Snippets sind erforderlich.", + "snippetNamePlaceholder": "Snippet-Name", + "snippetContentPlaceholder": "Befehlsinhalt", + "snippetFolderPlaceholder": "Ordner (optional)", + "snippetSaved": "Ausschnitt gespeichert", + "snippetSaveFailed": "Fehler beim Speichern des Ausschnitts", + "deleteSnippetConfirm": "Ausschnitt \"{{name}}\" zu {{username}} löschen?", + "snippetDeletedSuccess": "Ausschnitt gelöscht", + "snippetDeleteFailed": "Fehler beim Löschen des Ausschnitts", + "noSessionsForUser": "Keine aktiven Sitzungen", + "deleteUserDangerDesc": "{{username}} und alle zugehörigen Daten (Hosts, Anmeldeinformationen, Snippets, Verlauf) werden endgültig gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.", + "deleteUserConfirm": "{{username}} und alle zugehörigen Daten dauerhaft löschen?", + "deleteUserAdminBlocked": "Entfernen Sie den Administratorstatus, bevor Sie diesen Benutzer löschen.", "createRoleRequired": "Name und Anzeigename sind erforderlich.", "createRoleSuccess": "Rolle \"{{name}}\" erstellt", "createRoleFailed": "Rolle konnte nicht erstellt werden", "apiKeyNameRequired": "Ein Schlüsselname ist erforderlich.", - "apiKeyUserRequired": "Benutzer-ID erforderlich", + "apiKeyUserRequired": "Benutzer-ID ist erforderlich", "apiKeyCreatedSuccess": "API-Schlüssel \"{{name}}\" erstellt", "apiKeyCreateFailed": "API-Schlüssel konnte nicht erstellt werden", "exportSuccess": "Datenbank erfolgreich exportiert", @@ -2807,29 +2927,38 @@ "hostDefaultsDesc": "Die Einstellungen werden beim Erstellen eines neuen Hosts automatisch angewendet. Einzelne Hosts können diese jedoch weiterhin überschreiben.", "hostDefaultsSocks5": "SOCKS5-Proxy", "hostDefaultsUseSocks5": "SOCKS5-Proxy aktivieren", - "hostDefaultsUseSocks5Desc": "SOCKS5-Proxy auf allen neuen Hosts vorab einrichten", + "hostDefaultsUseSocks5Desc": "SOCKS5-Proxy bei allen neuen Hosts vorbelegen", "hostDefaultsSocks5Host": "Proxy-Host / Port", "hostDefaultsSocks5Username": "Proxy-Benutzername", "hostDefaultsSocks5Password": "Proxy-Passwort", "hostDefaultsMetrics": "Host-Metriken", "hostDefaultsMetricsEnabled": "Metriken aktivieren", - "hostDefaultsMetricsEnabledDesc": "CPU-, Speicher- und andere Statistiken werden standardmäßig auf neuen Hosts erfasst.", + "hostDefaultsMetricsEnabledDesc": "CPU-, Arbeitsspeicher- und andere Statistiken bei neuen Hosts standardmäßig erfassen", "hostDefaultsStatusCheckEnabled": "Statusprüfung aktivieren", - "hostDefaultsStatusCheckEnabledDesc": "Standardmäßig wird der Online-/Offline-Status neuer Hosts abgefragt.", + "hostDefaultsStatusCheckEnabledDesc": "Online-/Offline-Status bei neuen Hosts standardmäßig abfragen", "hostDefaultsTerminal": "Terminal", "hostDefaultsSessionLogging": "Sitzungsprotokollierung", - "hostDefaultsSessionLoggingDesc": "Terminal-Sitzungen auf neuen Hosts werden standardmäßig aufgezeichnet", + "hostDefaultsSessionLoggingDesc": "Terminal-Sitzungen bei neuen Hosts standardmäßig aufzeichnen", "hostDefaultsCommandHistory": "Befehlshistorie", - "hostDefaultsCommandHistoryDesc": "Befehlsverlauf auf neuen Hosts standardmäßig verfolgen", - "hostDefaultsSaved": "Standardeinstellungen des Hosts gespeichert", - "hostDefaultsSaveFailed": "Fehler beim Speichern der Host-Standardeinstellungen" + "hostDefaultsCommandHistoryDesc": "Befehlsverlauf bei neuen Hosts standardmäßig aufzeichnen", + "hostDefaultsSaved": "Host-Standardeinstellungen gespeichert", + "hostDefaultsSaveFailed": "Fehler beim Speichern der Host-Standardeinstellungen", + "rolePermissions": { + "count": "{{count}} Berechtigungen", + "editAction": "Bearbeitungsberechtigungen", + "loadError": "Der Berechtigungskatalog konnte nicht geladen werden.", + "saved": "Rollenberechtigungen gespeichert", + "saveError": "Rollenberechtigungen konnten nicht gespeichert werden", + "save": "Speichern", + "saving": "Speichern..." + } }, "newUi": { "sidebar": { "quickConnect": { - "hostLabel": "Gastgeber", + "hostLabel": "Host", "hostPlaceholder": "192.168.1.1 oder example.com", - "portLabel": "Hafen", + "portLabel": "Port", "portPlaceholder": "22", "usernameLabel": "Benutzername", "usernamePlaceholder": "Benutzername", @@ -2838,15 +2967,15 @@ "passwordPlaceholder": "Passwort", "privateKeyLabel": "Privater Schlüssel", "privateKeyPlaceholder": "Privaten Schlüssel einfügen...", - "credentialLabel": "Berechtigung", - "credentialPlaceholder": "Wählen Sie gespeicherte Anmeldeinformationen aus", + "credentialLabel": "Zugangsdaten", + "credentialPlaceholder": "Gespeicherte Zugangsdaten auswählen", "connectToTerminal": "Verbindung zum Terminal herstellen", "connectToFiles": "Verbindung zu Dateien herstellen" }, "history": { "noTerminalSelected": "Kein Terminal ausgewählt", "noTerminalSelectedHint": "Öffnen Sie einen SSH-Terminal-Tab, um dessen Befehlsverlauf anzuzeigen.", - "searchPlaceholder": "Suchverlauf...", + "searchPlaceholder": "Verlauf durchsuchen...", "clearAll": "Alles löschen", "noHistoryEntries": "Keine Verlaufseinträge", "trackingDisabled": "Verlaufsprotokollierung ist deaktiviert", @@ -2854,48 +2983,48 @@ }, "sshTools": { "keyRecordingTitle": "Tastenaufzeichnung", - "recordToTerminals": "Aufzeichnung an Terminals", + "recordToTerminals": "Auf Terminals aufzeichnen", "selectAll": "Alle", - "selectNone": "Keiner", + "selectNone": "Keine", "noTerminalTabsOpen": "Keine Terminal-Tabs geöffnet", - "selectTerminalsAbove": "Wählen Sie oben die gewünschten Anschlüsse aus.", + "selectTerminalsAbove": "Oben Terminals auswählen", "broadcastInputPlaceholder": "Hier tippen, um Tastatureingaben zu übertragen...", "fillPassword": "Passwort eingeben", "fillPasswordSuccess": "Passwort in {{count}} Terminal(s) eingegeben", - "fillPasswordMissing": "Für das/die ausgewählte(n) Terminal(s) {{count}} wurde kein Passwort gespeichert.", + "fillPasswordMissing": "Für {{count}} ausgewählte Terminal(s) ist kein Passwort gespeichert.", "stopRecording": "Aufnahme stoppen", "startRecording": "Aufnahme starten", "settingsTitle": "Einstellungen", - "enableRightClickCopyPaste": "Rechtsklick-Kopieren/Einfügen aktivieren" + "enableRightClickCopyPaste": "Kopieren/Einfügen per Rechtsklick aktivieren" }, "splitScreen": { "layoutTitle": "Layout", "selectLayoutAbove": "Wählen Sie oben ein Layout aus.", "selectLayoutHint": "Wählen Sie die Anzahl der anzuzeigenden Bereiche aus.", - "panesTitle": "Scheiben", - "openTabsTitle": "Offene Tabs", - "dragTabsHint": "Ziehen Sie Registerkarten in die oberen Bereiche oder verwenden Sie die Schnellzuweisung.", - "dropHere": "Hier einwerfen", + "panesTitle": "Bereiche", + "openTabsTitle": "Geöffnete Tabs", + "dragTabsHint": "Ziehen Sie Tabs in die Bereiche oben oder verwenden Sie die Schnellzuweisung.", + "dropHere": "Hier ablegen", "emptyPane": "Leer", - "dashboard": "Armaturenbrett", - "clearSplitScreen": "Split-Screen-Ansicht", + "dashboard": "Dashboard", + "clearSplitScreen": "Geteilte Ansicht leeren", "quickAssign": "Schnellzuweisung", - "alreadyAssigned": "Pane {{index}}", - "splitTab": "Split Tab", - "addToSplit": "Zu Split hinzufügen", - "removeFromSplit": "Aus Split entfernen", + "alreadyAssigned": "Bereich {{index}}", + "splitTab": "Tab teilen", + "addToSplit": "Zur geteilten Ansicht hinzufügen", + "removeFromSplit": "Aus geteilter Ansicht entfernen", "assignToPane": "Dem Bereich zuweisen", "hotkeysTitle": "Tastenkombinationen", - "hotkeysSplitRight": "2-Wege-Split umschalten", - "hotkeysSplitBelow": "3-Wege-Aufteilung umschalten", - "hotkeysNavigatePane": "Navigationsbereiche", + "hotkeysSplitRight": "Zweiteilung umschalten", + "hotkeysSplitBelow": "Dreifachteilung umschalten", + "hotkeysNavigatePane": "Zwischen Bereichen navigieren", "hotkeysNextTab": "Nächster Tab", "hotkeysPrevTab": "Vorheriger Tab" }, "snippets": { - "title": "Ausschnitte", + "title": "Snippets", "createSnippetTitle": "Snippet erstellen", - "createSnippetDescription": "Erstellen Sie einen neuen Befehlsausschnitt zur schnellen Ausführung", + "createSnippetDescription": "Erstellen Sie ein neues Befehls-Snippet zur schnellen Ausführung.", "nameLabel": "Name", "namePlaceholder": "z. B. Nginx neu starten", "descriptionLabel": "Beschreibung", @@ -2905,10 +3034,10 @@ "noFolder": "Kein Ordner (Nicht kategorisiert)", "commandLabel": "Befehl", "commandPlaceholder": "z. B. sudo systemctl restart nginx", - "cancel": "Stornieren", + "cancel": "Abbrechen", "createSnippetButton": "Snippet erstellen", "createFolderTitle": "Ordner erstellen", - "createFolderDescription": "Organisieren Sie Ihre Code-Schnipsel in Ordnern.", + "createFolderDescription": "Organisieren Sie Ihre Snippets in Ordnern.", "folderNameLabel": "Ordnername", "folderNamePlaceholder": "z. B. Systembefehle, Docker-Skripte", "folderColorLabel": "Ordnerfarbe", @@ -2918,22 +3047,22 @@ "createFolderButton": "Ordner erstellen", "targetTerminals": "Zielterminals", "selectAll": "Alle", - "selectNone": "Keiner", + "selectNone": "Keine", "noTerminalTabsOpen": "Keine Terminal-Tabs geöffnet", - "searchPlaceholder": "Suchergebnisse...", - "newSnippet": "Neuer Ausschnitt", + "searchPlaceholder": "Snippets durchsuchen...", + "newSnippet": "Neues Snippet", "newFolder": "Neuer Ordner", - "run": "Laufen", + "run": "Ausführen", "noSnippetsInFolder": "Keine Snippets in diesem Ordner", "uncategorized": "Nicht kategorisiert", - "editSnippetTitle": "Ausschnitt bearbeiten", - "editSnippetDescription": "Aktualisieren Sie diesen Befehlsausschnitt", + "editSnippetTitle": "Snippet bearbeiten", + "editSnippetDescription": "Dieses Befehls-Snippet aktualisieren", "saveSnippetButton": "Änderungen speichern", "createSuccess": "Snippet erfolgreich erstellt", "createFailed": "Fehler beim Erstellen des Snippets", "updateSuccess": "Snippet erfolgreich aktualisiert", - "updateFailed": "Fehler beim Aktualisieren des Codeausschnitts", - "deleteFailed": "Fehler beim Löschen des Ausschnitts", + "updateFailed": "Snippet konnte nicht aktualisiert werden", + "deleteFailed": "Snippet konnte nicht gelöscht werden", "folderCreateSuccess": "Ordner erfolgreich erstellt", "folderCreateFailed": "Ordner konnte nicht erstellt werden", "editFolderTitle": "Ordner bearbeiten", @@ -2945,60 +3074,64 @@ "folderDeleteFailed": "Ordner konnte nicht gelöscht werden", "folderEditSuccess": "Ordner erfolgreich aktualisiert", "folderEditFailed": "Ordner konnte nicht aktualisiert werden", - "confirmRunMessage": "Run \"{{name}}\"?", - "confirmRunButton": "Laufen", - "runSuccess": "Ran \"{{name}}\" in {{count}} terminal(s)", + "confirmRunMessage": "„{{name}}“ ausführen?", + "confirmRunButton": "Ausführen", + "runSuccess": "„{{name}}“ in {{count}} Terminal(s) ausgeführt", "copySuccess": "\"{{name}}\" in die Zwischenablage kopiert", - "shareTitle": "Ausschnitt teilen", + "shareTitle": "Snippet teilen", "shareUser": "Benutzer", "shareRole": "Rolle", - "selectUser": "Wählen Sie einen Benutzer aus...", - "selectRole": "Wählen Sie eine Rolle aus...", - "shareSuccess": "Ausschnitt erfolgreich geteilt", - "shareFailed": "Ausschnitt konnte nicht geteilt werden", + "selectUser": "Benutzer auswählen...", + "selectRole": "Rolle auswählen...", + "shareSuccess": "Snippet erfolgreich geteilt", + "shareFailed": "Snippet konnte nicht freigegeben werden", "revokeSuccess": "Zugriff widerrufen", - "revokeFailed": "Zugriff widerrufen fehlgeschlagen", + "revokeFailed": "Zugriff konnte nicht widerrufen werden", "currentAccess": "Aktueller Zugriff", "shareLoadError": "Die Freigabedaten konnten nicht geladen werden.", - "loading": "Laden...", + "loading": "Wird geladen...", "close": "Schließen", - "reorderFailed": "Fehler beim Speichern der Snippet-Reihenfolge", + "reorderFailed": "Snippet-Reihenfolge konnte nicht gespeichert werden", "importExport": "Import / Export", "exportBtn": "JSON exportieren", "importBtn": "JSON importieren", "exportSuccess": "Snippets erfolgreich exportiert", - "exportFailed": "Fehler beim Exportieren der Codeausschnitte", - "importTitle": "Import-Snippets", + "exportFailed": "Snippets konnten nicht exportiert werden", + "importTitle": "Snippets importieren", "importDescription": "Importieren Sie Snippets und Ordner aus einer von Termix exportierten JSON-Datei.", - "importDropOrClick": "Hier eine JSON-Datei hochladen oder zum Durchsuchen klicken", + "importDropOrClick": "JSON-Datei hier ablegen oder zum Auswählen klicken", "importSelectedFile": "Ausgewählt: {{name}}", - "importOverwrite": "Vorhandene Snippets mit demselben Namen und Ordner werden überschrieben.", - "importStartBtn": "Import", + "importOverwrite": "Vorhandene Snippets mit demselben Namen und Ordner überschreiben", + "importStartBtn": "Importieren", "importSuccess": "Import abgeschlossen: {{snippets}} Snippets hinzugefügt, {{updated}} aktualisiert, {{skipped}} übersprungen, {{folders}} Ordner hinzugefügt", - "importFailed": "Fehler beim Importieren der Code-Snippets", + "importFailed": "Snippets konnten nicht importiert werden", "importInvalidFile": "Ungültige Datei: Es wurde ein JSON-Objekt mit Snippets- oder Ordner-Arrays erwartet.", "targetHostsLabel": "Zielhosts", - "targetHostsHint": "Weisen Sie Hosts zu, diesen Codeabschnitt direkt ohne geöffnetes Terminal auszuführen.", + "targetHostsHint": "Weisen Sie Hosts zu, auf denen dieses Snippet direkt ohne geöffnetes Terminal ausgeführt werden soll.", "noHostsAvailable": "Keine Hosts konfiguriert", "clearTargetHosts": "Alles löschen", - "hasTargetHosts": "Hat Zielhosts", - "runOnTargets": "Lauf auf Ziele", - "directRunSuccess": "Ran \"{{name}}\" on {{count}} host(s)", + "hasTargetHosts": "Mit Zielhosts", + "runOnTargets": "Auf Zielsystemen ausführen", + "directRunSuccess": "„{{name}}“ auf {{count}} Host(s) ausgeführt", "directRunPartialFail": "\"{{name}}\" ist auf einem oder mehreren Hosts fehlgeschlagen.", "executionResultTitle": "Ausführungsergebnisse: {{name}}", - "executionResultDescription": "Ausgabe beim Ausführen des Code-Snippets auf jedem Zielhost.", + "executionResultDescription": "Ausgabe der Snippet-Ausführung auf den einzelnen Zielhosts.", "executionSuccess": "Erfolg", "executionFailed": "Fehlgeschlagen" }, "userProfile": { + "donateTitle": "Termix am Leben erhalten", + "donateDescription": "Termix ist kostenlos, selbstgehostet und wird von wenigen Personen in ihrer Freizeit entwickelt. Wenn es Ihnen Zeit oder Geld gespart hat, hilft eine Krypto-Spende, das Projekt weiterzuführen.", + "donateMilestones": "Spenden helfen uns, die nötige Zeit für Recherche und das Erlernen der erforderlichen Kenntnisse für die Entwicklung von SAML-, Kubernetes- und Agent-Unterstützung zu finanzieren. Verfolgen Sie den Fortschritt und spenden Sie.", + "donateButton": "Spenden Sie Kryptowährung", "storageModeLocal": "Browser", "storageModeCloud": "Datenbank", "storageModeDescription": "Der Browser speichert die Einstellungen nur in diesem Browser. Die Datenbank wird mit dem Server synchronisiert und auf jedem Gerät geladen.", "resetToDefaults": "Auf Standardeinstellungen zurücksetzen", "resetToDefaultsSuccess": "Einstellungen auf Standardwerte zurückgesetzt.", - "storageModeSwitch": "Speicherung von Präferenzen", + "storageModeSwitch": "Einstellungsspeicher", "sectionAccount": "Konto", - "sectionAppearance": "Aussehen", + "sectionAppearance": "Erscheinungsbild", "sectionSecurity": "Sicherheit", "sectionApiKeys": "API-Schlüssel", "sectionC2sTunnels": "C2S-Tunnel", @@ -3008,24 +3141,28 @@ "authMethodLabel": "Authentifizierungsmethode", "authMethodLocal": "Lokal", "twoFaLabel": "2FA", - "twoFaOn": "An", + "twoFaOn": "Ein", "twoFaOff": "Aus", "versionLabel": "Version", + "betaProgramTitle": "Beta-Programm", + "betaProgramDescription": "Testen Sie kommende Funktionen frühzeitig mit dem wöchentlich aktualisierten :beta-Docker-Tag. Instabil, nicht für den Produktiveinsatz geeignet.", + "betaProgramFeedback": "Einen Fehler gefunden? Melden Sie ihn hier.", + "betaProgramLearnMore": "Mehr erfahren", "deleteAccount": "Konto löschen", - "deleteAccountDescription": "Löschen Sie Ihr Konto endgültig.", + "deleteAccountDescription": "Konto dauerhaft löschen", "changeServerDescription": "Wechseln Sie zu einem anderen Termix-Backend-Server.", "deleteButton": "Löschen", - "deleteAccountPermanent": "Diese Maßnahme ist endgültig und kann nicht rückgängig gemacht werden.", - "deleteAccountWarning": "Alle Sitzungen, Hosts, Anmeldeinformationen und Einstellungen werden dauerhaft gelöscht.", + "deleteAccountPermanent": "Diese Aktion ist endgültig und kann nicht rückgängig gemacht werden.", + "deleteAccountWarning": "Alle Sitzungen, Hosts, Zugangsdaten und Einstellungen werden dauerhaft gelöscht.", "confirmPasswordDeletePlaceholder": "Geben Sie Ihr Passwort zur Bestätigung ein.", "languageLabel": "Sprache", - "themeLabel": "Thema", + "themeLabel": "Design", "fontSizeLabel": "Schriftgröße", "accentColorLabel": "Akzentfarbe", "settingsTerminal": "Terminal", "commandAutocomplete": "Befehlsvervollständigung", "commandAutocompleteDesc": "Autovervollständigung während der Eingabe anzeigen", - "terminalLinkBehavior": "Terminal-Link anklicken", + "terminalLinkBehavior": "Klickverhalten für Terminal-Links", "terminalLinkBehaviorDesc": "Standardverhalten beim Klicken auf Links im Terminal", "historyTracking": "Verlaufsverfolgung", "historyTrackingDesc": "Terminalbefehle verfolgen", @@ -3033,31 +3170,31 @@ "commandPaletteDesc": "Tastenkombination aktivieren", "reopenTabsOnLogin": "Tabs beim Anmelden wieder öffnen", "reopenTabsOnLoginDesc": "Ihre geöffneten Tabs werden beim Anmelden oder Aktualisieren der Seite wiederhergestellt, sogar von einem anderen Gerät aus.", - "confirmTabClose": "Tab schließen bestätigen", + "confirmTabClose": "Schließen von Tabs bestätigen", "confirmTabCloseDesc": "Vor dem Schließen der Terminal-Tabs fragen.", "settingsSidebar": "Seitenleiste", - "showHostTags": "Moderator-Tags anzeigen", + "showHostTags": "Host-Tags anzeigen", "showHostTagsDesc": "Tags in der Hostliste anzeigen", - "hostTrayOnClick": "Klicken Sie hier, um die Host-Aktionen zu erweitern.", + "hostTrayOnClick": "Klicken, um Host-Aktionen einzublenden", "hostTrayOnClickDesc": "Verbindungsschaltflächen immer anzeigen; zum Erweitern der Verwaltungsoptionen klicken statt mit der Maus darüberfahren", "compactHostView": "Kompakte Host-Ansicht", "compactHostViewDesc": "Reduzieren Sie jeden Host auf eine einzelne Zeile, die nur seinen Namen und seine Adresse anzeigt.", - "statusColors": "Real Status Farben", + "statusColors": "Echte Statusfarben", "statusColorsDesc": "Verwenden Sie Grün/Rot für den Online-/Offline-Status anstelle der Akzentfarbe.", - "pinAppRail": "Pin App Rail", - "pinAppRailDesc": "Die linke Seitenleiste der App-Bereiche sollte immer ausgeklappt sein, anstatt sich erst beim Überfahren mit der Maus zu erweitern.", - "expandAppRailOnHover": "App-Leiste beim Überfahren mit der Maus einklappen", + "pinAppRail": "App-Leiste anheften", + "pinAppRailDesc": "Die App-Leiste in der linken Seitenleiste immer ausgeklappt lassen, statt sie erst beim Darüberfahren zu erweitern.", + "expandAppRailOnHover": "App-Leiste beim Überfahren erweitern", "expandAppRailOnHoverDesc": "Ermöglichen Sie es, dass sich die linke Seitenleiste beim Überfahren mit dem Mauszeiger automatisch erweitert.", "settingsNavigation": "Navigation", "navigationTabsDesc": "Wählen Sie aus, welche Tabs in der App-Leiste angezeigt werden sollen.", - "settingsSnippets": "Ausschnitte", - "foldersCollapsed": "Ordner ausgeblendet", - "foldersCollapsedDesc": "Ordner standardmäßig ausblenden", + "settingsSnippets": "Snippets", + "foldersCollapsed": "Ordner eingeklappt", + "foldersCollapsedDesc": "Ordner standardmäßig einklappen", "confirmExecution": "Ausführung bestätigen", - "confirmExecutionDesc": "Vor dem Ausführen der Code-Snippets bestätigen", + "confirmExecutionDesc": "Vor dem Ausführen von Snippets bestätigen", "settingsUpdates": "Aktualisierungen", "disableUpdateChecks": "Updateprüfungen deaktivieren", - "disableUpdateChecksDesc": "Hören Sie auf, nach Updates zu suchen.", + "disableUpdateChecksDesc": "Nicht mehr nach Updates suchen", "totpAuthenticator": "TOTP-Authentifikator", "totpEnabled": "2FA ist aktiviert", "totpDisabled": "Zusätzliche Anmeldesicherheit hinzufügen", @@ -3065,10 +3202,10 @@ "enable": "Aktivieren", "setupTotp": "TOTP einrichten", "qrCode": "QR-Code", - "totpInstructions": "Scannen Sie den QR-Code oder geben Sie das Geheimnis in Ihrer Authentifizierungs-App ein und geben Sie anschließend den 6-stelligen Code ein.", + "totpInstructions": "Scannen Sie den QR-Code oder geben Sie den geheimen Schlüssel in Ihrer Authentifizierungs-App ein. Geben Sie anschließend den 6-stelligen Code ein.", "totpCodePlaceholder": "000000", - "verify": "Verifizieren", - "changePassword": "Kennwort ändern", + "verify": "Überprüfen", + "changePassword": "Passwort ändern", "currentPasswordLabel": "Aktuelles Passwort", "currentPasswordPlaceholder": "Aktuelles Passwort", "newPasswordLabel": "Neues Passwort", @@ -3077,20 +3214,20 @@ "confirmPasswordPlaceholder": "Neues Passwort bestätigen", "updatePassword": "Passwort aktualisieren", "createApiKeyTitle": "API-Schlüssel erstellen", - "createApiKeyDescription": "Generieren Sie einen neuen API-Schlüssel für den programmatischen Zugriff.", + "createApiKeyDescription": "Generieren Sie einen neuen API-Schlüssel für den programmgesteuerten Zugriff.", "apiKeyNameLabel": "Name", "apiKeyNamePlaceholder": "z. B. CI-Pipeline", - "expiryDateLabel": "Verfallsdatum", + "expiryDateLabel": "Ablaufdatum", "optional": "optional", - "cancel": "Stornieren", + "cancel": "Abbrechen", "createKey": "Schlüssel erstellen", - "apiKeyCount": "{{count}} Tasten", + "apiKeyCount": "{{count}} Schlüssel", "newKey": "Neuer Schlüssel", "noApiKeys": "Noch keine API-Schlüssel.", "apiKeyActive": "Aktiv", - "apiKeyUsageHint": "Legen Sie Ihren Schlüssel bei", - "apiKeyUsageHintHeader": "Kopfzeile.", - "apiKeyPermissionsHint": "Schlüssel erben die Berechtigungen des erstellenden Benutzers.", + "apiKeyUsageHint": "Fügen Sie Ihren Schlüssel in den", + "apiKeyUsageHintHeader": "Header.", + "apiKeyPermissionsHint": "Schlüssel übernehmen die Berechtigungen des Benutzers, der sie erstellt hat.", "roleUser": "Benutzer", "authMethodDual": "Duale Authentifizierung", "authMethodOidc": "OIDC", @@ -3104,24 +3241,24 @@ "totpDisableTitle": "Zwei-Faktor-Authentifizierung deaktivieren", "totpDisablePlaceholder": "Geben Sie den TOTP-Code oder das Passwort ein.", "totpDisableConfirm": "Zwei-Faktor-Authentifizierung deaktivieren", - "totpContinueVerify": "Überprüfen Sie weiter", + "totpContinueVerify": "Weiter zur Überprüfung", "totpVerifyTitle": "Code überprüfen", "totpBackupTitle": "Backup-Codes", "totpDownloadBackup": "Backup-Codes herunterladen", "passkeys": "Passkeys", - "passkeysDesc": "Verwenden Sie Ihre WebAuthn/FIDO2-Anmeldeinformationen, um sich ohne Passwort anzumelden.", + "passkeysDesc": "Verwenden Sie WebAuthn-/FIDO2-Anmeldeinformationen, um sich ohne Passwort anzumelden.", "passkeyName": "Passkey-Name", "passkeyUvPreferred": "Bevorzugt", "passkeyUvRequired": "Erforderlich", - "passkeyUvDiscouraged": "Entmutigt", + "passkeyUvDiscouraged": "Nicht empfohlen", "addPasskey": "Passkey hinzufügen", - "noPasskeys": "Es sind keine Passwörter registriert.", + "noPasskeys": "Keine Passkeys registriert.", "passkeyAdded": "Passkey hinzugefügt", "passkeyAddFailed": "Passkey konnte nicht hinzugefügt werden", "passkeyDeleted": "Passkey gelöscht", "passkeyDeleteFailed": "Fehler beim Löschen des Passkeys", - "done": "Erledigt", - "secretCopied": "Geheimnis in die Zwischenablage kopiert", + "done": "Fertig", + "secretCopied": "Geheimschlüssel in die Zwischenablage kopiert", "apiKeyNameRequired": "Ein Schlüsselname ist erforderlich.", "apiKeyCreated": "API-Schlüssel \"{{name}}\" erstellt", "apiKeyCreateFailed": "API-Schlüssel konnte nicht erstellt werden", @@ -3133,20 +3270,20 @@ "passwordMismatch": "Die Passwörter stimmen nicht überein.", "passwordTooShort": "Das Passwort muss mindestens 6 Zeichen lang sein.", "passwordUpdated": "Passwort erfolgreich aktualisiert", - "passwordUpdateFailed": "Passwortaktualisierung fehlgeschlagen", + "passwordUpdateFailed": "Passwort konnte nicht aktualisiert werden", "deletePasswordRequired": "Zum Löschen Ihres Kontos ist ein Passwort erforderlich.", "deleteFailed": "Konto konnte nicht gelöscht werden", - "deleting": "Löschen...", + "deleting": "Wird gelöscht...", "colorPickerTooltip": "Farbauswahl öffnen", "themeSystem": "System", - "themeLight": "Licht", + "themeLight": "Hell", "themeDark": "Dunkel", "themeDracula": "Dracula", "themeCatppuccin": "Catppuccin", "themeNord": "Nord", - "themeSolarized": "Solarisiert", - "themeTokyoNight": "Tokio bei Nacht", - "themeOneDark": "Ein Dunkel", + "themeSolarized": "Solarized", + "themeTokyoNight": "Tokyo Night", + "themeOneDark": "One Dark", "themeGruvbox": "Gruvbox" } } @@ -3158,14 +3295,14 @@ "tmuxUnavailable": "tmux ist auf diesem Host nicht installiert.", "noSessions": "Auf diesem Host sind keine tmux-Sitzungen aktiv.", "noHostSelected": "Kein Host ausgewählt", - "attached": "Beigefügt", - "detached": "Losgelöst", - "attach": "Befestigen", + "attached": "Verbunden", + "detached": "Getrennt", + "attach": "Anhängen", "editTags": "Tags bearbeiten", "tagsHint": "Durch Kommas getrennte Schlagwörter (z. B. YOLO, Labor, Training)", "tagsSaved": "Tags gespeichert", "tagsSaveFailed": "Tags konnten nicht gespeichert werden", - "searchPlaceholder": "Suchergebnisse über alle Sitzungen hinweg...", + "searchPlaceholder": "Ausgaben sitzungsübergreifend durchsuchen...", "searchResults": "{{count}} Übereinstimmungen", "searchFailed": "Suche fehlgeschlagen", "selectPaneHint": "Wählen Sie einen Bereich aus, um dessen Ausgabe in der Vorschau anzuzeigen.", @@ -3176,9 +3313,9 @@ "create": "Erstellen", "sessionCreated": "Sitzung \"{{name}}\" erstellt", "sessionCreateFailed": "Sitzung konnte nicht erstellt werden", - "splitRight": "Rechts abzweigen", - "splitDown": "Aufteilen", - "splitFailed": "Fehler beim Teilen des Fensters", + "splitRight": "Nach rechts teilen", + "splitDown": "Nach unten teilen", + "splitFailed": "Bereich konnte nicht geteilt werden", "newWindow": "Neues Fenster", "windowCreateFailed": "Fenster konnte nicht erstellt werden", "attachSessionTooltip": "An {{session}} anhängen", @@ -3188,47 +3325,47 @@ "expandAll": "Alle erweitern", "moreActions": "Weitere Aktionen", "sessionStats": "Sitzungsstatistiken", - "renameSessionTitle": "Sitzung umbenennen \"{{name}}\"", + "renameSessionTitle": "Sitzung \"{{name}}\" umbenennen", "rename": "Umbenennen", - "sessionRenamed": "Die Sitzung wurde umbenannt in \"{{name}}\"", - "sessionRenameFailed": "Fehler beim Umbenennen der Sitzung", - "editTagsTitle": "Tags für \"{{name}} bearbeiten \"", - "killPane": "Kill-Fenster", + "sessionRenamed": "Sitzung in \"{{name}}\" umbenannt", + "sessionRenameFailed": "Sitzung konnte nicht umbenannt werden", + "editTagsTitle": "Tags für \"{{name}}\" bearbeiten", + "killPane": "Bereich schließen", "resizeTree": "Ziehen zum Ändern der Größe – Doppelklick zum Zurücksetzen", "reattach": "Wieder anhängen (behebt eine fehlerhafte Ansicht)", - "killWindow": "Tötungsfenster", - "killWindowTitle": "Kill-Fenster {{index}} von \"{{session}}\"?", + "killWindow": "Fenster schließen", + "killWindowTitle": "Fenster {{index}} von \"{{session}}\" schließen?", "killWindowBody": "Alle Bereiche und Prozesse in diesem Fenster werden beendet. Durch Schließen des letzten Fensters wird die Sitzung beendet.", "windowKillFailed": "Fenster konnte nicht geschlossen werden", "statusActivity": "Aktivität", - "statusWindows": "Windows", - "statusPanes": "Scheiben", + "statusWindows": "Fenster", + "statusPanes": "Bereiche", "statusTags": "Tags", - "killPaneTitle": "Kill pane {{id}}?", - "killPaneBody": "Der in diesem Fenster laufende Prozess wird beendet. Durch das Schließen des letzten Fensters wird dessen Fenster geschlossen.", - "paneKillFailed": "Fehler beim Schließen des Fensters", - "killSessionTitle": "Sitzung beenden \"{{name}}\"?", + "killPaneTitle": "Bereich {{id}} schließen?", + "killPaneBody": "Der in diesem Bereich laufende Prozess wird beendet. Durch das Schließen des letzten Bereichs wird das zugehörige Fenster geschlossen.", + "paneKillFailed": "Bereich konnte nicht geschlossen werden", + "killSessionTitle": "Sitzung \"{{name}}\" beenden?", "killSessionBody": "Alle Fenster und laufenden Prozesse dieser Sitzung werden beendet. Dies kann nicht rückgängig gemacht werden.", - "kill": "Töten", + "kill": "Beenden", "sessionKilled": "Sitzung \"{{name}}\" beendet", "sessionKillFailed": "Sitzung konnte nicht beendet werden", "hostUnreachable": "Es konnte keine Verbindung zum Host hergestellt werden. Bitte überprüfen Sie, ob er online und erreichbar ist.", "noServer": "Auf diesem Host läuft kein tmux-Server.", - "searchTruncated": "Teilergebnisse — die Suche umfasst die letzten {{lines}} Zeilen jedes Fensters und höchstens {{panes}} Fenster.", + "searchTruncated": "Teilergebnisse — die Suche umfasst die letzten {{lines}} Zeilen jedes Bereichs und höchstens {{panes}} Bereiche.", "closeSearchResults": "Suchergebnisse schließen", "retry": "Wiederholen", "noHosts": "Keine SSH-Hosts verfügbar", - "noHostsHint": "Aktivieren Sie die Option Tmux Monitor auf einem SSH-Host (Host Manager → Registerkarte Terminal), um dessen tmux-Sitzungen zu überwachen.", + "noHostsHint": "Aktivieren Sie auf einem SSH-Host die Option „Tmux-Monitor“ (Host-Manager → Tab „Terminal“), um dessen tmux-Sitzungen zu überwachen.", "tmuxInstallHint": "Installieren Sie es auf dem Host mit:", - "attachTooltip": "Öffnen Sie ein Terminal, um {{host}}", - "attachTooltipPane": "Öffne ein Terminal, um {{host}} — tmux-Sitzung {{session}} zu geben.", + "attachTooltip": "Terminal für {{host}} öffnen", + "attachTooltipPane": "Terminal für {{host}} öffnen — tmux-Sitzung {{session}}", "timeJustNow": "soeben", - "timeMinutes": "{{count}}m ago", - "timeHours": "{{count}}h ago", - "timeDays": "{{count}}Tage her" + "timeMinutes": "vor {{count}} Min.", + "timeHours": "vor {{count}} Std.", + "timeDays": "vor {{count}} T." }, "mobileKeyboard": { - "shift": "Schicht", + "shift": "Umschalt", "ctrl": "Strg", "esc": "Esc", "tab": "Tab", @@ -3237,32 +3374,32 @@ "arrowDown": "Pfeil nach unten", "arrowLeft": "Pfeil nach links", "arrowRight": "Pfeil nach rechts", - "home": "Heim", + "home": "Pos1", "end": "Ende", - "pageUp": "Bild aufwärts", + "pageUp": "Bild auf", "pageDown": "PgDn", "delete": "Del", "editQuickKeys": "Schnellzugriffstasten bearbeiten", "quickKeysTitle": "Schnellzugriffstasten", - "quickKeysDesc": "Tippen Sie auf ×, um es zu entfernen. Unterstützt bis zu 8 Zeichen.", - "quickKeyPlaceholder": "z.B. sudo ", + "quickKeysDesc": "Tippen Sie zum Entfernen auf ×. Es werden bis zu 8 Zeichen unterstützt.", + "quickKeyPlaceholder": "z. B. sudo ", "addQuickKey": "Hinzufügen", "removeQuickKey": "Entfernen", "resetDefaults": "Auf Standardeinstellungen zurücksetzen", - "done": "Erledigt" + "done": "Fertig" }, "serial": { - "title": "Seriennummer", - "portLabel": "Hafen", + "title": "Serielle Verbindung", + "portLabel": "Port", "portPlaceholder": "/dev/ttyUSB0 oder COM3", "baudRateLabel": "Baudrate", "dataBitsLabel": "Daten", - "stopBitsLabel": "Stoppen", + "stopBitsLabel": "Stoppbits", "parityLabel": "Parität", - "parityNone": "Keiner", - "parityEven": "Sogar", - "parityOdd": "Seltsam", - "connect": "Serielle Schnittstelle", + "parityNone": "Keine", + "parityEven": "Gerade", + "parityOdd": "Ungerade", + "connect": "Serielle Verbindung herstellen", "disconnect": "Trennen", "refreshPorts": "Ports aktualisieren", "connected": "Verbunden mit {{path}} bei {{baud}} Baud", @@ -3270,21 +3407,21 @@ "connectionError": "Serielle Schnittstelle konnte nicht geöffnet werden", "wsError": "WebSocket-Fehler", "errorNoServerUrl": "Keine Server-URL konfiguriert", - "notSupportedTitle": "Seriennummer nicht unterstützt", + "notSupportedTitle": "Serielle Verbindungen nicht unterstützt", "notSupported": "Für serielle Verbindungen wird ein Browser mit Unterstützung für die Web Serial API benötigt (Chrome, Edge oder Firefox 151+), alternativ die Termix-Desktop-App.", - "hideHint": "Sie können die Registerkarte „Seriennummer“ unter Benutzerprofil > Darstellung > Seitenleiste > Navigation ausblenden.", + "hideHint": "Sie können den Tab „Seriell“ unter Benutzerprofil > Darstellung > Seitenleiste > Navigation ausblenden.", "browserPickerHint": "Klicken Sie auf „Verbinden“, und Ihr Browser öffnet eine Portauswahl, in der Sie das Gerät auswählen können." }, "metricsHistory": { "title": "Metrikverlauf", - "historySuffix": "Geschichte", + "historySuffix": "Verlauf", "cpuMemoryDisk": "CPU / Arbeitsspeicher / Festplatte", "network": "Netzwerk", "download": "RX", "upload": "TX", "noData": "Für diesen Zeitraum sind keine historischen Daten verfügbar.", - "custom": "Brauch", - "to": "Zu", + "custom": "Benutzerdefiniert", + "to": "bis", "apply": "Anwenden", "viewHistory": "Verlauf anzeigen" }, @@ -3293,17 +3430,17 @@ "tabRules": "Regeln", "tabChannels": "Kanäle", "noFirings": "Keine unbestätigten Warnmeldungen", - "noRules": "Es sind keine Alarmregeln konfiguriert.", + "noRules": "Keine Warnregeln konfiguriert", "noChannels": "Es sind keine Benachrichtigungskanäle konfiguriert.", - "rulesDesc": "Alarmregeln lösen Benachrichtigungen aus", - "channelsDesc": "Wohin werden Warnmeldungen gesendet?", - "acknowledge": "Anerkennen", - "ackAll": "Alle nicken", - "allAcknowledged": "Alle Benachrichtigungen bestätigt", - "ackFailed": "Alarm nicht bestätigt", - "ackAllFailed": "Alle Warnmeldungen wurden nicht bestätigt.", + "rulesDesc": "Warnregeln lösen Benachrichtigungen aus", + "channelsDesc": "Ziel für Warnmeldungen", + "acknowledge": "Bestätigen", + "ackAll": "Alle bestätigen", + "allAcknowledged": "Alle Warnmeldungen bestätigt", + "ackFailed": "Warnmeldung konnte nicht bestätigt werden", + "ackAllFailed": "Warnmeldungen konnten nicht alle bestätigt werden", "showAcknowledged": "Alle anzeigen", - "hideAcknowledged": "Ausblenden Bestätigt", + "hideAcknowledged": "Bestätigte ausblenden", "addChannel": "Kanal hinzufügen", "editChannel": "Kanal bearbeiten", "channelName": "Name", @@ -3313,21 +3450,21 @@ "webhookUrlRequired": "Die Webhook-URL ist erforderlich.", "webhookDesc": "Bei jeder Alarmauslösung wird eine JSON-Payload an diese URL gesendet.", "ntfyServer": "Server-URL", - "ntfyTopic": "Thema", - "ntfyTopicRequired": "Thema erforderlich", + "ntfyTopic": "Topic", + "ntfyTopicRequired": "Topic ist erforderlich", "ntfyToken": "Zugriffstoken (optional)", "channelSaveFailed": "Kanal konnte nicht gespeichert werden", "test": "Prüfen", "testSent": "Testbenachrichtigung gesendet", "testFailed": "Testbenachrichtigung fehlgeschlagen", - "addRule": "Benachrichtigungsregel hinzufügen", - "editRule": "Benachrichtigungsregel bearbeiten", + "addRule": "Warnregel hinzufügen", + "editRule": "Warnregel bearbeiten", "ruleName": "Regelname", "ruleNameRequired": "Name erforderlich", - "triggerType": "Auslösen", + "triggerType": "Auslöser", "thresholdValue": "Schwelle (%)", "durationSeconds": "Dauer (Sekunden, 0 = sofortiges Auslösen)", - "cooldownMinutes": "Abkühlzeit (Minuten)", + "cooldownMinutes": "Abklingzeit (Minuten)", "channels": "Benachrichtigungskanäle", "noChannelsHint": "Fügen Sie zuerst Kanäle auf der Registerkarte „Kanäle“ hinzu.", "ruleSaveFailed": "Regel konnte nicht gespeichert werden" diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index 78209cab..13c66583 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Μοτίβα ονομάτων διαχωρισμένα με κόμματα που επιτρέπουν στο Docker να αντιστοιχίζει επισκέπτες", "proxmoxPreferredRanges": "Προτιμώμενα εύρη IP", "proxmoxPreferredRangesDesc": "Προθέματα διαχωρισμένα με κόμμα σε σειρά προτεραιότητας για την επιλογή IP όταν ένας επισκέπτης έχει πολλαπλές διεπαφές", + "proxmoxAutoSync": "Αυτόματος συγχρονισμός επισκεπτών", + "proxmoxAutoSyncDesc": "Εντοπίζετε περιοδικά αυτόν τον κόμβο Proxmox και δημιουργείτε ή ενημερώνετε εισαγόμενους φιλοξενούμενους υπολογιστές όσο η συνεδρία σας είναι ξεκλείδωτη.", + "proxmoxSyncInterval": "Διάστημα συγχρονισμού (λεπτά)", + "proxmoxSyncIntervalDesc": "Ελάχιστο 5 λεπτά. Ο προγραμματιστής παραλείπει τις κλειδωμένες συνεδρίες χρήστη.", + "proxmoxMarkMissing": "Ο Μαρκ λείπουν επισκέπτες.", + "proxmoxMarkMissingDesc": "Προσθέστε μια ετικέτα proxmox-missing όταν εξαφανιστεί ένας προηγουμένως εισαγόμενος επισκέπτης αντί να τον διαγράψετε.", + "proxmoxLastSync": "Τελευταίος συγχρονισμός", + "proxmoxLastSyncNever": "Δεν έχει συγχρονιστεί ακόμα", + "proxmoxLastSyncNoResult": "Δεν υπάρχουν λεπτομέρειες για το αποτέλεσμα", + "proxmoxLastSyncSummary": "{{created}} δημιουργήθηκε, {{updated}} ενημερώθηκε, {{markedMissing}} λείπει, {{skipped}} παραλείφθηκε", + "proxmoxLastSyncStatus": { + "success": "Επιτυχία", + "error": "Αποτυχημένος", + "pending": "Εκκρεμής" + }, "proxmoxDiscoverAction": "Ανακάλυψη και εισαγωγή επισκεπτών Proxmox", "proxmoxImportTitle": "Εισαγωγή από το Proxmox", "proxmoxSelectHost": "Επιλέξτε έναν κεντρικό υπολογιστή Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Κοινή χρήση κεντρικού υπολογιστή", "shareHostTitle": "Κοινή χρήση: {{name}}", "sharing": { - "requiresCredential": "Αυτός ο κεντρικός υπολογιστής πρέπει να χρησιμοποιήσει ένα διαπιστευτήριο για να ενεργοποιήσει την κοινή χρήση. Επεξεργαστείτε τον κεντρικό υπολογιστή και εκχωρήστε πρώτα ένα διαπιστευτήριο." + "loadError": "Αποτυχία φόρτωσης δεδομένων κοινής χρήσης. Δοκιμάστε ξανά.", + "shareWithSection": "Κοινοποίηση με", + "usersTab": "Χρήστες", + "rolesTab": "Ρόλοι", + "searchPlaceholder": "Αναζήτηση χρηστών ή ρόλων...", + "noMatches": "Δεν βρέθηκαν αντιστοιχίσεις", + "permissionLevelLabel": "Επίπεδο δικαιωμάτων", + "levels": { + "connect": { + "label": "Συνδέω", + "description": "Μόνο ανοιχτές συνεδρίες: τερματικό, απομακρυσμένη επιφάνεια εργασίας, διαχειριστής αρχείων, σήραγγες και Docker. Δεν υπάρχει πρόσβαση στη διαμόρφωση του κεντρικού υπολογιστή." + }, + "view": { + "label": "Θέα", + "description": "Συνδεθείτε και δείτε τη διαμόρφωση του κεντρικού υπολογιστή. Τα μυστικά δεν εμφανίζονται ποτέ." + }, + "edit": { + "label": "Εκδίδω", + "description": "Προβολή και τροποποίηση του κεντρικού υπολογιστή. Τα μυστικά μπορούν να αντικατασταθούν αλλά ποτέ να διαβαστούν. Οι εκχωρήσεις διαπιστευτηρίων παραμένουν μόνο για τον κάτοχο." + }, + "manage": { + "label": "Διαχειρίζομαι", + "description": "Επεξεργαστείτε και μοιραστείτε τον κεντρικό υπολογιστή με άλλους, αλλάξτε τα επίπεδα δικαιωμάτων και ανακαλέστε την πρόσβαση." + } + }, + "expiryLabel": "Λήξη πρόσβασης", + "expiry": { + "never": "Ποτέ", + "oneHour": "1 ώρα", + "oneDay": "24 ώρες", + "sevenDays": "7 ημέρες", + "thirtyDays": "30 ημέρες", + "custom": "Εθιμο" + }, + "customHoursPlaceholder": "Ώρες μέχρι τη λήξη της πρόσβασης", + "shareButton": "Μερίδιο", + "shareWithCount": "Κοινή χρήση ({{count}})", + "currentAccess": "Τρέχουσα πρόσβαση", + "noAccessEntries": "Αυτός ο κεντρικός υπολογιστής δεν έχει κοινοποιηθεί ακόμη", + "grantedBy": "Χορηγήθηκε από", + "expires": "Λήγει", + "expired": "Λήξη", + "never": "Ποτέ", + "revoke": "Ανακαλώ", + "accessUpdated": "Η πρόσβαση ενημερώθηκε", + "accessUpdateFailed": "Η ενημέρωση της πρόσβασης απέτυχε", + "sharedBadge": "Κοινόχρηστο", + "sharedBadgeTooltip": "Κοινή χρήση από {{owner}} ( πρόσβαση{{level}})", + "viewOnlyBanner": "Αυτός ο κεντρικός υπολογιστής είναι κοινόχρηστος μαζί σας από τον χρήστη {{owner}} με πρόσβαση προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.", + "sharedEditBanner": "Αυτός ο κεντρικός υπολογιστής είναι κοινόχρηστος μαζί σας από τον χρήστη {{owner}} με πρόσβαση επεξεργασίας. Οι αλλαγές ισχύουν για τον πραγματικό κεντρικό υπολογιστή. Οι αναφορές ελέγχου ταυτότητας μπορούν να αλλάξουν μόνο από τον κάτοχο.", + "ownerOnlyControl": "Μόνο ο κάτοχος του κεντρικού υπολογιστή μπορεί να το αλλάξει αυτό" }, "guac": { "connection": "Σύνδεση", @@ -1302,28 +1367,10 @@ "backspaceKey": "Πλήκτρο Backspace", "saveHostFirst": "Αποθηκεύστε πρώτα τον οικοδεσπότη.", "sharingOptionsAfterSave": "Οι επιλογές κοινής χρήσης είναι διαθέσιμες μετά την αποθήκευση του κεντρικού υπολογιστή.", - "sharingLoadError": "Αποτυχία φόρτωσης δεδομένων κοινής χρήσης. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", - "shareHostSection": "Κοινή χρήση κεντρικού υπολογιστή", - "shareWithUser": "Κοινή χρήση με χρήστη", - "shareWithRole": "Κοινή χρήση με ρόλο", - "selectUser": "Επιλογή χρήστη", - "selectRole": "Επιλογή Ρόλου", - "selectUserOption": "Επιλέξτε έναν χρήστη...", - "selectRoleOption": "Επιλέξτε έναν ρόλο...", "permissionLevel": "Επίπεδο δικαιωμάτων", - "expiresInHours": "Λήγει σε (ώρες)", - "noExpiryPlaceholder": "Αφήστε κενό για να μην λήξει", - "shareBtn": "Μερίδιο", - "currentAccess": "Τρέχουσα πρόσβαση", "typeHeader": "Τύπος", "targetHeader": "Στόχος", "permissionHeader": "Αδεια", - "grantedByHeader": "Χορηγήθηκε από", - "expiresHeader": "Λήγει", - "noAccessEntries": "Δεν υπάρχουν ακόμη καταχωρίσεις πρόσβασης.", - "expiredLabel": "Λήξη", - "neverLabel": "Ποτέ", - "revokeBtn": "Ανακαλώ", "cancelBtn": "Ματαίωση", "savingBtn": "Οικονομία...", "updateHostBtn": "Ενημέρωση κεντρικού υπολογιστή", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Η σύνδεση με κωδικό πρόσβασης είναι απενεργοποιημένη. Χρησιμοποιήστε κλειδί πρόσβασης ή εξωτερικό πάροχο ελέγχου ταυτότητας.", "signInWithPasskey": "Σύνδεση με κλειδί πρόσβασης", "passkeyLoginFailed": "Η σύνδεση με κωδικό πρόσβασης απέτυχε", - "attemptsRemaining": "{{count}} προσπάθειες απομένουν" + "attemptsRemaining": "{{count}} προσπάθειες απομένουν", + "confirmResetDataWipe": "Αυτός ο λογαριασμός δεν έχει συνδεθεί από την αναβάθμιση κρυπτογράφησης, επομένως τα αποθηκευμένα δεδομένα του δεν μπορούν να ανακτηθούν χωρίς τον παλιό κωδικό πρόσβασης. Η επαναφορά θα διαγράψει οριστικά τους κεντρικούς υπολογιστές, τα διαπιστευτήρια και τα αποσπάσματα κώδικα. Θέλετε να συνεχίσετε;" }, "hostKey": { "verifyNewHost": "Επαλήθευση κλειδιού κεντρικού υπολογιστή SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Σύνδεσμοι υπηρεσιών", "homepagePreview": "Προεπισκόπηση αρχικής σελίδας" }, + "donation": { + "title": "Σας αρέσει το Termix;", + "body": "Το Termix είναι δωρεάν και ανοιχτού κώδικα, κατασκευασμένο και συντηρείται από μια ομάδα δύο ατόμων στον ελεύθερο χρόνο μας. Εάν αντικαταστήσει ένα εμπορικό εργαλείο για το οποίο διαφορετικά θα πληρώνατε, μια δωρεά βοηθά στην κάλυψη του κόστους φιλοξενίας και διατηρεί την ανάπτυξη σε εξέλιξη. Προς το παρόν, οι δωρεές είναι μόνο για κρυπτονομίσματα.", + "milestones": "Οι δωρεές βοηθούν επίσης στη χρηματοδότηση του χρόνου για έρευνα και εκμάθηση των απαραίτητων λειτουργιών για τη δημιουργία λειτουργιών όπως η υποστήριξη SAML και Kubernetes. Δείτε την πρόοδο στη σελίδα δωρεών.", + "cta": "Προσφέρω", + "dismiss": "Ίσως αργότερα" + }, "dashboardTab": { "stable": "ΣΤΑΘΕΡΟΣ", "hostsOnline": "Οικοδεσπότες στο Διαδίκτυο", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Η ενημέρωση της κατάστασης διαχειριστή απέτυχε", "allSessionsRevoked": "Όλες οι συνεδρίες ανακλήθηκαν", "revokeSessionsFailed": "Η ανάκληση των συνεδριών απέτυχε", + "manageUserData": "Διαχείριση δεδομένων χρήστη", + "backToUsers": "Πίσω στους χρήστες", + "manageTabAccount": "Λογαριασμός", + "manageTabHosts": "Οικοδεσπότες", + "manageTabCredentials": "Διαπιστευτήρια", + "manageTabSnippets": "Αποσπάσματα", + "manageTabSessions": "Συνεδρίες", + "manageTabDanger": "Κίνδυνος", + "manageEditorBack": "Πίσω στα δεδομένα του {{username}}", + "dataLockedBadge": "ΚΛΕΙΔΩΜΕΝΟ", + "dataLockedNotice": "Τα δεδομένα του χρήστη {{username}}παραμένουν κλειδωμένα μέχρι την επόμενη σύνδεσή τους. Οι κεντρικοί υπολογιστές, τα διαπιστευτήρια και τα αποσπάσματα δεδομένων τους δεν μπορούν να προβληθούν ή να επεξεργαστούν μέχρι τότε.", + "resetPasswordTitle": "Επαναφορά κωδικού πρόσβασης", + "resetPasswordOidcOnly": "Αυτός ο χρήστης συνδέεται μέσω εξωτερικού παρόχου και δεν έχει κωδικό πρόσβασης.", + "resetPasswordPlaceholder": "Νέος κωδικός πρόσβασης", + "resetPasswordBtn": "Επαναφορά", + "resetPasswordWorking": "Επαναφορά...", + "resetPasswordSuccess": "Επαναφορά κωδικού πρόσβασης", + "resetPasswordSuccessWiped": "Επαναφορά κωδικού πρόσβασης. Τα κρυπτογραφημένα δεδομένα του χρήστη διαγράφηκαν.", + "resetPasswordFailed": "Η επαναφορά του κωδικού πρόσβασης απέτυχε", + "resetPasswordConfirmWipe": "Ο χρήστης {{username}} δεν έχει συνδεθεί από την αναβάθμιση κρυπτογράφησης, επομένως τα δεδομένα του δεν μπορούν να ανακτηθούν. Η επαναφορά τώρα θα διαγράψει τους κεντρικούς υπολογιστές, τα διαπιστευτήρια και τα αποσπάσματα κώδικα. Θέλετε να συνεχίσετε;", + "totpSectionTitle": "Έλεγχος ταυτότητας δύο παραγόντων", + "totpStatusEnabled": "Το TOTP είναι ενεργοποιημένο για αυτόν τον χρήστη", + "totpStatusDisabled": "Το TOTP δεν είναι ενεργοποιημένο για αυτόν τον χρήστη", + "disableTotp": "Καθιστώ ανίκανο", + "disableTotpConfirm": "Απενεργοποίηση ελέγχου ταυτότητας δύο παραγόντων για {{username}}; Θα μπορούν να συνδεθούν μόνο με τον κωδικό πρόσβασής τους.", + "totpDisabledSuccess": "Το TOTP απενεργοποιήθηκε", + "totpDisableFailed": "Αποτυχία απενεργοποίησης του TOTP", + "manageApiKeys": "Κλειδιά API", + "apiKeyNamePlaceholder": "Όνομα κλειδιού", + "apiKeyCopyNotice": "Αντιγράψτε αυτό το κλειδί τώρα, δεν θα εμφανιστεί ξανά.", + "noApiKeysForUser": "Δεν υπάρχουν κλειδιά API", + "apiKeyDeleteFailed": "Η διαγραφή του κλειδιού API απέτυχε", + "exportUserData": "Εξαγωγή δεδομένων", + "exportUserDataDesc": "Λήψη των κεντρικών υπολογιστών, των διαπιστευτηρίων και των δεδομένων διαχειριστή αρχείων αυτού του χρήστη ως JSON. Τα μυστικά αποκρυπτογραφούνται.", + "exportUserDataSuccess": "Εξαγωγή δεδομένων χρήστη", + "exportUserDataFailed": "Η εξαγωγή δεδομένων χρήστη απέτυχε", + "hostsCount": "{{count}} κεντρικοί υπολογιστές", + "addHostForUser": "Προσθήκη κεντρικού υπολογιστή", + "noHostsForUser": "Αυτός ο χρήστης δεν έχει κεντρικούς υπολογιστές", + "connectToHost": "Συνδέω", + "deleteHostConfirm": "Διαγραφή κεντρικού υπολογιστή \"{{name}}\" που ανήκει στον {{username}};", + "hostDeletedSuccess": "Ο κεντρικός υπολογιστής διαγράφηκε.", + "hostDeleteFailed": "Η διαγραφή του κεντρικού υπολογιστή απέτυχε", + "credentialsCount": "{{count}} διαπιστευτήρια", + "addCredentialForUser": "Προσθήκη διαπιστευτηρίων", + "noCredentialsForUser": "Αυτός ο χρήστης δεν έχει διαπιστευτήρια", + "deleteCredentialConfirm": "Διαγραφή διαπιστευτηρίου \"{{name}}\" που ανήκει στο {{username}};", + "credentialDeletedSuccess": "Τα διαπιστευτήρια διαγράφηκαν", + "credentialDeleteFailed": "Η διαγραφή των διαπιστευτηρίων απέτυχε", + "snippetsCount": "{{count}} αποσπάσματα", + "addSnippetForUser": "Προσθήκη αποσπάσματος", + "noSnippetsForUser": "Αυτός ο χρήστης δεν έχει αποσπάσματα", + "snippetRequiredFields": "Απαιτούνται το όνομα και το περιεχόμενο του αποσπάσματος", + "snippetNamePlaceholder": "Όνομα αποσπάσματος", + "snippetContentPlaceholder": "Περιεχόμενο εντολών", + "snippetFolderPlaceholder": "Φάκελος (προαιρετικό)", + "snippetSaved": "Το απόσπασμα αποθηκεύτηκε", + "snippetSaveFailed": "Η αποθήκευση του αποσπάσματος απέτυχε", + "deleteSnippetConfirm": "Διαγραφή αποσπάσματος \"{{name}}\" που ανήκει στο {{username}};", + "snippetDeletedSuccess": "Το απόσπασμα διαγράφηκε", + "snippetDeleteFailed": "Η διαγραφή του αποσπάσματος απέτυχε", + "noSessionsForUser": "Δεν υπάρχουν ενεργές συνεδρίες", + "deleteUserDangerDesc": "Διαγράψτε οριστικά το {{username}} και όλα τα δεδομένα του (κεντρικούς υπολογιστές, διαπιστευτήρια, τμήματα κώδικα, ιστορικό). Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", + "deleteUserConfirm": "Να διαγραφεί οριστικά ο χρήστης {{username}} και όλα τα δεδομένα του;", + "deleteUserAdminBlocked": "Καταργήστε την κατάσταση διαχειριστή πριν διαγράψετε αυτόν τον χρήστη.", "createRoleRequired": "Απαιτούνται το όνομα και το εμφανιζόμενο όνομα", "createRoleSuccess": "Ο ρόλος \"{{name}}\" δημιουργήθηκε", "createRoleFailed": "Η δημιουργία ρόλου απέτυχε", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Ιστορικό εντολών", "hostDefaultsCommandHistoryDesc": "Παρακολούθηση ιστορικού εντολών σε νέους κεντρικούς υπολογιστές από προεπιλογή", "hostDefaultsSaved": "Οι προεπιλογές κεντρικού υπολογιστή αποθηκεύτηκαν", - "hostDefaultsSaveFailed": "Η αποθήκευση των προεπιλογών του κεντρικού υπολογιστή απέτυχε" + "hostDefaultsSaveFailed": "Η αποθήκευση των προεπιλογών του κεντρικού υπολογιστή απέτυχε", + "rolePermissions": { + "count": "{{count}} δικαιώματα", + "editAction": "Επεξεργασία δικαιωμάτων", + "loadError": "Αποτυχία φόρτωσης του καταλόγου δικαιωμάτων", + "saved": "Τα δικαιώματα ρόλου αποθηκεύτηκαν", + "saveError": "Αποτυχία αποθήκευσης δικαιωμάτων ρόλου", + "save": "Εκτός", + "saving": "Οικονομία..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Αποτυχημένος" }, "userProfile": { + "donateTitle": "Κρατήστε το Termix ζωντανό", + "donateDescription": "Το Termix είναι δωρεάν, αυτο-φιλοξενείται και δημιουργείται από λίγα μόνο άτομα στον ελεύθερο χρόνο τους. Αν σας έχει εξοικονομήσει χρόνο ή χρήματα, μια δωρεά κρυπτονομισμάτων βοηθάει στη συνέχιση της λειτουργίας του.", + "donateMilestones": "Οι δωρεές βοηθούν στη χρηματοδότηση του χρόνου για έρευνα και εκμάθηση των απαραίτητων για την ανάπτυξη υποστήριξης SAML, Kubernetes και Agent. Δείτε την πρόοδο και κάντε μια δωρεά.", + "donateButton": "Δωρίστε κρυπτονομίσματα", "storageModeLocal": "Πρόγραμμα περιήγησης", "storageModeCloud": "Βάση δεδομένων", "storageModeDescription": "Το πρόγραμμα περιήγησης αποθηκεύει τις ρυθμίσεις μόνο σε αυτό το πρόγραμμα περιήγησης. Η βάση δεδομένων συγχρονίζεται με τον διακομιστή και φορτώνει σε οποιαδήποτε συσκευή.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Επί", "twoFaOff": "Μακριά από", "versionLabel": "Εκδοχή", + "betaProgramTitle": "Πρόγραμμα Beta", + "betaProgramDescription": "Δοκιμάστε νωρίς τις επερχόμενες λειτουργίες με την εβδομαδιαία κυλιόμενη ετικέτα Docker :beta. Ασταθές, δεν είναι για παραγωγή.", + "betaProgramFeedback": "Βρήκατε κάποιο σφάλμα; Αναφέρετέ το εδώ.", + "betaProgramLearnMore": "Μάθετε περισσότερα", "deleteAccount": "Διαγραφή λογαριασμού", "deleteAccountDescription": "Οριστική διαγραφή του λογαριασμού σας", "changeServerDescription": "Μετάβαση σε διαφορετικό διακομιστή backend Termix", diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index cb8d1b9b..e22e5637 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Patrones de nombres separados por comas que permiten a Docker hacer coincidir los invitados.", "proxmoxPreferredRanges": "Rangos de IP preferidos", "proxmoxPreferredRangesDesc": "Prefijos separados por comas en orden de prioridad para la selección de IP cuando un invitado tiene múltiples interfaces.", + "proxmoxAutoSync": "Sincronización automática de invitados", + "proxmoxAutoSyncDesc": "Descubre periódicamente este nodo Proxmox y crea o actualiza los hosts invitados importados mientras tu sesión esté desbloqueada.", + "proxmoxSyncInterval": "Intervalo de sincronización (minutos)", + "proxmoxSyncIntervalDesc": "Mínimo 5 minutos. El programador omite las sesiones de usuario bloqueadas.", + "proxmoxMarkMissing": "Mark, huéspedes desaparecidos", + "proxmoxMarkMissingDesc": "Cuando un invitado importado previamente desaparece, se añade la etiqueta proxmox-missing en lugar de eliminarlo.", + "proxmoxLastSync": "Última sincronización", + "proxmoxLastSyncNever": "Aún no sincronizado", + "proxmoxLastSyncNoResult": "No hay detalles del resultado", + "proxmoxLastSyncSummary": "{{created}} creado, {{updated}} actualizado, {{markedMissing}} faltante, {{skipped}} omitido", + "proxmoxLastSyncStatus": { + "success": "Éxito", + "error": "Fallido", + "pending": "Pendiente" + }, "proxmoxDiscoverAction": "Descubre e importa invitados de Proxmox", "proxmoxImportTitle": "Importar desde Proxmox", "proxmoxSelectHost": "Seleccione un host Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Compartir anfitrión", "shareHostTitle": "Compartir: {{name}}", "sharing": { - "requiresCredential": "Este host debe usar una credencial para habilitar el uso compartido. Edite el host y asigne una credencial primero." + "loadError": "No se pudieron cargar los datos para compartir. Inténtelo de nuevo.", + "shareWithSection": "Compartir con", + "usersTab": "Usuarios", + "rolesTab": "Roles", + "searchPlaceholder": "Buscar usuarios o roles...", + "noMatches": "No se encontraron coincidencias", + "permissionLevelLabel": "Nivel de permiso", + "levels": { + "connect": { + "label": "Conectar", + "description": "Solo se permiten sesiones abiertas: terminal, escritorio remoto, administrador de archivos, túneles y Docker. No se permite el acceso a la configuración del host." + }, + "view": { + "label": "Vista", + "description": "Conéctate y consulta la configuración del host. Las claves secretas nunca se muestran." + }, + "edit": { + "label": "Editar", + "description": "Visualice y modifique el host. Los secretos se pueden reemplazar, pero nunca leer; las asignaciones de credenciales permanecen solo para el propietario." + }, + "manage": { + "label": "Administrar", + "description": "Edita, comparte el host con otros, cambia los niveles de permisos y revoca el acceso." + } + }, + "expiryLabel": "Caducidad del acceso", + "expiry": { + "never": "Nunca", + "oneHour": "1 hora", + "oneDay": "24 horas", + "sevenDays": "7 días", + "thirtyDays": "30 días", + "custom": "Costumbre" + }, + "customHoursPlaceholder": "Horas hasta que caduque el acceso", + "shareButton": "Compartir", + "shareWithCount": "Compartir ({{count}})", + "currentAccess": "Acceso actual", + "noAccessEntries": "Este host aún no se ha compartido.", + "grantedBy": "Concedido por", + "expires": "Caduca", + "expired": "Venció", + "never": "Nunca", + "revoke": "Revocar", + "accessUpdated": "Acceso actualizado", + "accessUpdateFailed": "No se pudo actualizar el acceso.", + "sharedBadge": "Compartido", + "sharedBadgeTooltip": "Compartido por {{owner}} ({{level}} acceso)", + "viewOnlyBanner": "Este host es compartido contigo por {{owner}} con acceso de visualización. La configuración es de solo lectura.", + "sharedEditBanner": "Este host es compartido contigo por {{owner}} con permisos de edición. Los cambios se aplican al host real; las referencias de autenticación solo pueden ser modificadas por el propietario.", + "ownerOnlyControl": "Solo el propietario del host puede cambiar esto." }, "guac": { "connection": "Conexión", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tecla de retroceso", "saveHostFirst": "Primero, salva al anfitrión.", "sharingOptionsAfterSave": "Las opciones para compartir estarán disponibles una vez que se haya guardado el host.", - "sharingLoadError": "No se pudieron cargar los datos para compartir. Compruebe su conexión e inténtelo de nuevo.", - "shareHostSection": "Compartir anfitrión", - "shareWithUser": "Compartir con el usuario", - "shareWithRole": "Compartir con rol", - "selectUser": "Seleccionar usuario", - "selectRole": "Seleccionar rol", - "selectUserOption": "Seleccione un usuario...", - "selectRoleOption": "Selecciona un rol...", "permissionLevel": "Nivel de permiso", - "expiresInHours": "Caduca en (horas)", - "noExpiryPlaceholder": "Dejar vacío para que no caduque.", - "shareBtn": "Compartir", - "currentAccess": "Acceso actual", "typeHeader": "Tipo", "targetHeader": "Objetivo", "permissionHeader": "Permiso", - "grantedByHeader": "Otorgado por", - "expiresHeader": "Caduca", - "noAccessEntries": "Aún no hay entradas de acceso.", - "expiredLabel": "Venció", - "neverLabel": "Nunca", - "revokeBtn": "Revocar", "cancelBtn": "Cancelar", "savingBtn": "Ahorro...", "updateHostBtn": "Host de actualización", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "El inicio de sesión con contraseña está deshabilitado. Utilice una clave de acceso o un proveedor de autenticación externo.", "signInWithPasskey": "Iniciar sesión con la clave de acceso", "passkeyLoginFailed": "Falló el inicio de sesión con clave de acceso.", - "attemptsRemaining": "{{count}} intentos restantes" + "attemptsRemaining": "{{count}} intentos restantes", + "confirmResetDataWipe": "Esta cuenta no ha iniciado sesión desde la actualización del cifrado, por lo que sus datos almacenados no se pueden recuperar sin la contraseña anterior. Restablecerla eliminará permanentemente sus hosts, credenciales y fragmentos de código. ¿Desea continuar?" }, "hostKey": { "verifyNewHost": "Verificar la clave de host SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Enlaces de servicio", "homepagePreview": "Vista previa de la página de inicio" }, + "donation": { + "title": "¿Te gusta Termix?", + "body": "Termix es gratuito y de código abierto, desarrollado y mantenido por un equipo de dos personas en nuestro tiempo libre. Si ha reemplazado una herramienta comercial por la que de otro modo pagarías, una donación ayuda a cubrir los costos de alojamiento y a mantener el desarrollo. Por el momento, solo se aceptan donaciones en criptomonedas.", + "milestones": "Las donaciones también ayudan a financiar el tiempo necesario para investigar y aprender lo que se requiere para desarrollar funciones como la compatibilidad con SAML y Kubernetes. Consulta el progreso en la página de donaciones.", + "cta": "Donar", + "dismiss": "Quizás más tarde" + }, "dashboardTab": { "stable": "ESTABLE", "hostsOnline": "Anfitriones en línea", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "No se pudo actualizar el estado del administrador.", "allSessionsRevoked": "Todas las sesiones revocadas", "revokeSessionsFailed": "No se pudieron revocar las sesiones.", + "manageUserData": "Gestionar datos de usuario", + "backToUsers": "Volver a los usuarios", + "manageTabAccount": "Cuenta", + "manageTabHosts": "Anfitriones", + "manageTabCredentials": "Cartas credenciales", + "manageTabSnippets": "Fragmentos", + "manageTabSessions": "Sesiones", + "manageTabDanger": "Peligro", + "manageEditorBack": "Volver a los datos de {{username}}", + "dataLockedBadge": "BLOQUEADO", + "dataLockedNotice": "Los datos de {{username}}permanecen bloqueados hasta que inicien sesión nuevamente. Sus hosts, credenciales y fragmentos no se pueden ver ni editar hasta entonces.", + "resetPasswordTitle": "Restablecer contraseña", + "resetPasswordOidcOnly": "Este usuario inicia sesión a través de un proveedor externo y no tiene contraseña.", + "resetPasswordPlaceholder": "Nueva contraseña", + "resetPasswordBtn": "Reiniciar", + "resetPasswordWorking": "Reiniciando...", + "resetPasswordSuccess": "Restablecimiento de contraseña", + "resetPasswordSuccessWiped": "Se ha restablecido la contraseña. Se han borrado los datos cifrados del usuario.", + "resetPasswordFailed": "No se pudo restablecer la contraseña.", + "resetPasswordConfirmWipe": "{{username}} no ha iniciado sesión desde la actualización del cifrado, por lo que sus datos no se pueden recuperar. Reiniciar ahora eliminará sus hosts, credenciales y fragmentos. ¿Continuar?", + "totpSectionTitle": "Autenticación de dos factores", + "totpStatusEnabled": "TOTP está habilitado para este usuario.", + "totpStatusDisabled": "TOTP no está habilitado para este usuario.", + "disableTotp": "Desactivar", + "disableTotpConfirm": "Deshabilitar la autenticación de dos factores para {{username}}? Podrán iniciar sesión solo con su contraseña.", + "totpDisabledSuccess": "TOTP deshabilitado", + "totpDisableFailed": "No se pudo desactivar TOTP.", + "manageApiKeys": "Claves API", + "apiKeyNamePlaceholder": "Nombre de la clave", + "apiKeyCopyNotice": "Copia esta clave ahora, no volverá a aparecer.", + "noApiKeysForUser": "Sin claves API", + "apiKeyDeleteFailed": "Error al eliminar la clave API", + "exportUserData": "Exportación de datos", + "exportUserDataDesc": "Descarga los hosts, las credenciales y los datos del administrador de archivos de este usuario en formato JSON. Los secretos están descifrados.", + "exportUserDataSuccess": "Datos de usuario exportados", + "exportUserDataFailed": "Error al exportar los datos del usuario.", + "hostsCount": "{{count}} anfitriones", + "addHostForUser": "Agregar host", + "noHostsForUser": "Este usuario no tiene hosts.", + "connectToHost": "Conectar", + "deleteHostConfirm": "¿Eliminar el host \"{{name}}\" perteneciente a {{username}}?", + "hostDeletedSuccess": "Host eliminado", + "hostDeleteFailed": "No se pudo eliminar el host", + "credentialsCount": "{{count}} credenciales", + "addCredentialForUser": "Agregar credencial", + "noCredentialsForUser": "Este usuario no tiene credenciales.", + "deleteCredentialConfirm": "¿Eliminar la credencial \"{{name}}\" perteneciente a {{username}}?", + "credentialDeletedSuccess": "Credencial eliminada", + "credentialDeleteFailed": "Error al eliminar las credenciales", + "snippetsCount": "{{count}} fragmentos", + "addSnippetForUser": "Agregar fragmento", + "noSnippetsForUser": "Este usuario no tiene fragmentos", + "snippetRequiredFields": "Se requiere el nombre y el contenido del fragmento.", + "snippetNamePlaceholder": "Nombre del fragmento", + "snippetContentPlaceholder": "Contenido del comando", + "snippetFolderPlaceholder": "Carpeta (opcional)", + "snippetSaved": "Fragmento guardado", + "snippetSaveFailed": "No se pudo guardar el fragmento.", + "deleteSnippetConfirm": "¿Eliminar el fragmento \"{{name}}\" perteneciente a {{username}}?", + "snippetDeletedSuccess": "Fragmento eliminado", + "snippetDeleteFailed": "No se pudo eliminar el fragmento.", + "noSessionsForUser": "No hay sesiones activas", + "deleteUserDangerDesc": "Eliminar permanentemente {{username}} y todos sus datos (hosts, credenciales, fragmentos, historial). Esta acción es irreversible.", + "deleteUserConfirm": "¿Eliminar permanentemente {{username}} y todos sus datos?", + "deleteUserAdminBlocked": "Elimine el estado de administrador antes de borrar a este usuario.", "createRoleRequired": "Se requiere nombre y nombre para mostrar.", "createRoleSuccess": "Rol \"{{name}}\" creado", "createRoleFailed": "No se pudo crear el rol.", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Historial de comandos", "hostDefaultsCommandHistoryDesc": "Registrar el historial de comandos en los nuevos hosts de forma predeterminada", "hostDefaultsSaved": "Configuración predeterminada del host guardada", - "hostDefaultsSaveFailed": "No se pudieron guardar los valores predeterminados del host." + "hostDefaultsSaveFailed": "No se pudieron guardar los valores predeterminados del host.", + "rolePermissions": { + "count": "{{count}} permisos", + "editAction": "Modificar permisos", + "loadError": "No se pudo cargar el catálogo de permisos.", + "saved": "Permisos de rol guardados", + "saveError": "No se pudieron guardar los permisos de rol.", + "save": "Ahorrar", + "saving": "Ahorro..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Fallido" }, "userProfile": { + "donateTitle": "Mantén Termix con vida", + "donateDescription": "Termix es gratuito, autoalojado y desarrollado por un pequeño grupo de personas en su tiempo libre. Si te ha ahorrado tiempo o dinero, una donación en criptomonedas ayuda a que siga funcionando.", + "donateMilestones": "Las donaciones ayudan a financiar el tiempo necesario para investigar y aprender lo que se requiere para desarrollar compatibilidad con SAML, Kubernetes y agentes. Sigue el progreso y dona.", + "donateButton": "Dona criptomonedas", "storageModeLocal": "Navegador", "storageModeCloud": "Base de datos", "storageModeDescription": "El navegador guarda la configuración únicamente en este navegador. La base de datos se sincroniza con el servidor y se carga en cualquier dispositivo.", @@ -3011,6 +3144,10 @@ "twoFaOn": "En", "twoFaOff": "Apagado", "versionLabel": "Versión", + "betaProgramTitle": "Programa Beta", + "betaProgramDescription": "Prueba las próximas funciones antes que nadie con la etiqueta Docker :beta, que se actualiza semanalmente. Es inestable y no apta para producción.", + "betaProgramFeedback": "¿Has encontrado un error? Infórmanos aquí.", + "betaProgramLearnMore": "Más información", "deleteAccount": "Eliminar cuenta", "deleteAccountDescription": "Eliminar permanentemente tu cuenta", "changeServerDescription": "Cambia a un servidor backend de Termix diferente.", diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index 60f35383..77468743 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Pilkuilla erotetut nimimallit, joiden avulla Docker löytää vieraat yhteensopiviksi", "proxmoxPreferredRanges": "Suositellut IP-alueet", "proxmoxPreferredRangesDesc": "Pilkuilla erotetut etuliitteet prioriteettijärjestyksessä IP-osoitteen valinnassa, kun vieraalla on useita rajapintoja", + "proxmoxAutoSync": "Synkronoi vieraat automaattisesti", + "proxmoxAutoSyncDesc": "Etsi tämä Proxmox-solmu säännöllisesti ja luo tai päivitä tuotuja vieraskomanteja istunnon ollessa lukitsematon.", + "proxmoxSyncInterval": "Synkronointiväli (minuuttia)", + "proxmoxSyncIntervalDesc": "Vähintään 5 minuuttia. Ajastin ohittaa lukitut käyttäjäistunnot.", + "proxmoxMarkMissing": "Merkitse puuttuvat vieraat", + "proxmoxMarkMissingDesc": "Lisää proxmox-missing-tunniste, kun aiemmin tuotu vieras katoaa sen poistamisen sijaan.", + "proxmoxLastSync": "Viimeisin synkronointi", + "proxmoxLastSyncNever": "Ei vielä synkronoitu", + "proxmoxLastSyncNoResult": "Ei tulostietoja", + "proxmoxLastSyncSummary": "{{created}} luotu, {{updated}} päivitetty, {{markedMissing}} puuttuu, {{skipped}} ohitettu", + "proxmoxLastSyncStatus": { + "success": "Menestys", + "error": "Epäonnistui", + "pending": "Odottaa" + }, "proxmoxDiscoverAction": "Löydä ja tuo Proxmox-vieraita", "proxmoxImportTitle": "Tuo Proxmoxista", "proxmoxSelectHost": "Valitse Proxmox-isäntä…", @@ -1167,7 +1182,57 @@ "shareHost": "Jaa isäntä", "shareHostTitle": "Jaa: {{name}}", "sharing": { - "requiresCredential": "Tämän isännän on käytettävä tunnistetietoja jakamisen mahdollistamiseksi. Muokkaa ensin isäntää ja määritä tunnistetiedot." + "loadError": "Jakamistietojen lataaminen epäonnistui. Yritä uudelleen.", + "shareWithSection": "Jaa kanssa", + "usersTab": "Käyttäjät", + "rolesTab": "Roolit", + "searchPlaceholder": "Hae käyttäjiä tai rooleja...", + "noMatches": "Ei osumia", + "permissionLevelLabel": "Käyttöoikeustaso", + "levels": { + "connect": { + "label": "Yhdistä", + "description": "Vain avoimet istunnot: pääte, etätyöpöytä, tiedostonhallinta, tunnelit ja Docker. Ei pääsyä isäntäkokoonpanoon." + }, + "view": { + "label": "Näytä", + "description": "Yhdistä ja katso isäntäkokoonpano. Salaisuuksia ei koskaan näytetä." + }, + "edit": { + "label": "Muokata", + "description": "Näytä ja muokkaa isäntää. Salaisuuksia voidaan korvata, mutta niitä ei voida koskaan lukea; tunnistetietojen määritykset pysyvät vain omistajalla." + }, + "manage": { + "label": "Hallitse", + "description": "Muokkaa ja jaa isäntä muiden kanssa, muuta käyttöoikeustasoja ja peruuta käyttöoikeuksia." + } + }, + "expiryLabel": "Käyttöoikeuden päättyminen", + "expiry": { + "never": "Ei koskaan", + "oneHour": "1 tunti", + "oneDay": "24 tuntia", + "sevenDays": "7 päivää", + "thirtyDays": "30 päivää", + "custom": "Mukautettu" + }, + "customHoursPlaceholder": "Tuntia käyttöoikeuden vanhenemiseen", + "shareButton": "Jakaa", + "shareWithCount": "Jaa ({{count}})", + "currentAccess": "Nykyinen käyttöoikeus", + "noAccessEntries": "Tätä isäntää ei ole vielä jaettu", + "grantedBy": "Myöntänyt", + "expires": "Vanhenee", + "expired": "Vanhentunut", + "never": "Ei koskaan", + "revoke": "Peruuttaa", + "accessUpdated": "Käyttöoikeus päivitetty", + "accessUpdateFailed": "Käyttöoikeuksien päivittäminen epäonnistui", + "sharedBadge": "Jaettu", + "sharedBadgeTooltip": "Jaettu käyttäjän {{owner}} toimesta ({{level}} pääsy)", + "viewOnlyBanner": "{{owner}} jakaa tämän isännän kanssasi ja hänellä on katseluoikeudet. Määritykset ovat vain luku -tilassa.", + "sharedEditBanner": "{{owner}} jakaa tämän isännän kanssasi muokkausoikeuksin. Muutokset koskevat varsinaista isäntää; vain omistaja voi muuttaa todennusviitteitä.", + "ownerOnlyControl": "Vain isännän omistaja voi muuttaa tätä" }, "guac": { "connection": "Yhteys", @@ -1302,28 +1367,10 @@ "backspaceKey": "Askelpalautin-näppäin", "saveHostFirst": "Tallenna ensin isäntä.", "sharingOptionsAfterSave": "Jakamisvaihtoehdot ovat käytettävissä isännän tallentamisen jälkeen.", - "sharingLoadError": "Jakamistietojen lataus epäonnistui. Tarkista yhteytesi ja yritä uudelleen.", - "shareHostSection": "Jaa isäntä", - "shareWithUser": "Jaa käyttäjän kanssa", - "shareWithRole": "Jaa roolin kanssa", - "selectUser": "Valitse käyttäjä", - "selectRole": "Valitse rooli", - "selectUserOption": "Valitse käyttäjä...", - "selectRoleOption": "Valitse rooli...", "permissionLevel": "Käyttöoikeustaso", - "expiresInHours": "Vanhenee (tuntia) kuluttua", - "noExpiryPlaceholder": "Jätä tyhjäksi, jos et halua vanhenevan", - "shareBtn": "Jakaa", - "currentAccess": "Nykyinen käyttöoikeus", "typeHeader": "Tyyppi", "targetHeader": "Kohde", "permissionHeader": "Lupa", - "grantedByHeader": "Myöntäjä", - "expiresHeader": "Vanhenee", - "noAccessEntries": "Ei vielä käyttöoikeusmerkintöjä.", - "expiredLabel": "Vanhentunut", - "neverLabel": "Ei koskaan", - "revokeBtn": "Peruuttaa", "cancelBtn": "Peruuttaa", "savingBtn": "Tallennetaan...", "updateHostBtn": "Päivitä isäntä", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Salasanalla kirjautuminen on poistettu käytöstä. Käytä salasanaa tai ulkoista todennuspalveluntarjoajaa.", "signInWithPasskey": "Kirjaudu sisään salasanalla", "passkeyLoginFailed": "Salasanan kirjautuminen epäonnistui", - "attemptsRemaining": "{{count}} yritystä jäljellä" + "attemptsRemaining": "{{count}} yritystä jäljellä", + "confirmResetDataWipe": "Tälle tilille ei ole kirjautunut sisään salauspäivityksen jälkeen, joten sen tallennettuja tietoja ei voida palauttaa ilman vanhaa salasanaa. Nollaaminen poistaa pysyvästi sen isännät, tunnistetiedot ja koodinpätkät. Jatketaanko?" }, "hostKey": { "verifyNewHost": "Vahvista SSH-isäntäavain", @@ -2323,6 +2371,13 @@ "serviceLinks": "Palvelulinkit", "homepagePreview": "Etusivun esikatselu" }, + "donation": { + "title": "Pidätkö Termixistä?", + "body": "Termix on ilmainen ja avoimen lähdekoodin ohjelmisto, jota kahden hengen tiimi rakentaa ja ylläpitää vapaa-ajallaan. Jos se korvaa kaupallisen työkalun, josta muuten maksaisit, lahjoitus auttaa kattamaan ylläpitokulut ja pitämään kehityksen käynnissä. Tällä hetkellä lahjoitukset ovat vain kryptovaluuttoja.", + "milestones": "Lahjoitukset auttavat myös rahoittamaan aikaa tutkimukseen ja oppimaan, mitä tarvitaan ominaisuuksien, kuten SAML:n ja Kubernetes-tuen, rakentamiseen. Katso edistyminen lahjoitussivulta.", + "cta": "Lahjoittaa", + "dismiss": "Ehkä myöhemmin" + }, "dashboardTab": { "stable": "VAKAA", "hostsOnline": "Isännät verkossa", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Ylläpitäjän tilan päivittäminen epäonnistui", "allSessionsRevoked": "Kaikki istunnot peruutettu", "revokeSessionsFailed": "Istuntojen peruuttaminen epäonnistui", + "manageUserData": "Hallinnoi käyttäjätietoja", + "backToUsers": "Takaisin käyttäjiin", + "manageTabAccount": "Tili", + "manageTabHosts": "Isännät", + "manageTabCredentials": "Valtakirjat", + "manageTabSnippets": "Katkelmat", + "manageTabSessions": "Istunnot", + "manageTabDanger": "Vaara", + "manageEditorBack": "Takaisin käyttäjän {{username}}tietoihin", + "dataLockedBadge": "LUKITTU", + "dataLockedNotice": "{{username}}:n tiedot pysyvät lukittuina, kunnes he kirjautuvat sisään seuraavalla kerralla. Heidän palvelimiaan, tunnistetietojaan ja koodinpätkiään ei voida tarkastella tai muokata ennen sitä.", + "resetPasswordTitle": "Palauta salasana", + "resetPasswordOidcOnly": "Tämä käyttäjä kirjautuu sisään ulkoisen palveluntarjoajan kautta eikä hänellä ole salasanaa.", + "resetPasswordPlaceholder": "Uusi salasana", + "resetPasswordBtn": "Nollaa", + "resetPasswordWorking": "Nollataan...", + "resetPasswordSuccess": "Salasanan palautus", + "resetPasswordSuccessWiped": "Salasanan palautus. Käyttäjän salatut tiedot on pyyhitty.", + "resetPasswordFailed": "Salasanan nollaaminen epäonnistui", + "resetPasswordConfirmWipe": "{{username}} ei ole kirjautunut sisään salauspäivityksen jälkeen, joten hänen tietojaan ei voida palauttaa. Nollaaminen nyt poistaa hänen palvelimensa, tunnistetietonsa ja koodinpätkänsä. Jatketaanko?", + "totpSectionTitle": "Kaksivaiheinen todennus", + "totpStatusEnabled": "TOTP on käytössä tälle käyttäjälle", + "totpStatusDisabled": "TOTP ei ole käytössä tälle käyttäjälle", + "disableTotp": "Poista käytöstä", + "disableTotpConfirm": "Poista kaksivaiheinen todennus käytöstä käyttäjälle {{username}}? He voivat kirjautua sisään pelkällä salasanallaan.", + "totpDisabledSuccess": "TOTP poistettu käytöstä", + "totpDisableFailed": "TOTP:n poistaminen käytöstä epäonnistui", + "manageApiKeys": "API-avaimet", + "apiKeyNamePlaceholder": "Avaimen nimi", + "apiKeyCopyNotice": "Kopioi tämä avain nyt, sitä ei näytetä enää uudelleen.", + "noApiKeysForUser": "Ei API-avaimia", + "apiKeyDeleteFailed": "API-avaimen poistaminen epäonnistui", + "exportUserData": "Tietojen vienti", + "exportUserDataDesc": "Lataa tämän käyttäjän isännät, tunnistetiedot ja tiedostonhallintadata JSON-muodossa. Salaisuudet on purettu.", + "exportUserDataSuccess": "Käyttäjätiedot viety", + "exportUserDataFailed": "Käyttäjätietojen vienti epäonnistui", + "hostsCount": "{{count}} isännät", + "addHostForUser": "Lisää isäntä", + "noHostsForUser": "Tällä käyttäjällä ei ole isäntiä", + "connectToHost": "Yhdistä", + "deleteHostConfirm": "Poistetaanko isäntä \"{{name}}\", joka kuuluu {{username}}:lle?", + "hostDeletedSuccess": "Isäntä poistettu", + "hostDeleteFailed": "Isännän poistaminen epäonnistui", + "credentialsCount": "{{count}} tunnistetiedot", + "addCredentialForUser": "Lisää tunnistetiedot", + "noCredentialsForUser": "Tällä käyttäjällä ei ole tunnuksia", + "deleteCredentialConfirm": "Poistetaanko tunniste \"{{name}}\", joka kuuluu käyttäjälle {{username}}?", + "credentialDeletedSuccess": "Tunnistetiedot poistettu", + "credentialDeleteFailed": "Tunnuksen poistaminen epäonnistui", + "snippetsCount": "{{count}} katkelmia", + "addSnippetForUser": "Lisää katkelma", + "noSnippetsForUser": "Tällä käyttäjällä ei ole katkelmia", + "snippetRequiredFields": "Katkelman nimi ja sisältö vaaditaan", + "snippetNamePlaceholder": "Katkelman nimi", + "snippetContentPlaceholder": "Komennon sisältö", + "snippetFolderPlaceholder": "Kansio (valinnainen)", + "snippetSaved": "Katkelma tallennettu", + "snippetSaveFailed": "Katkelman tallentaminen epäonnistui", + "deleteSnippetConfirm": "Poistetaanko koodinpätkä \"{{name}}\", joka kuuluu käyttäjälle {{username}}?", + "snippetDeletedSuccess": "Katkelma poistettu", + "snippetDeleteFailed": "Katkelman poistaminen epäonnistui", + "noSessionsForUser": "Ei aktiivisia istuntoja", + "deleteUserDangerDesc": "Poista pysyvästi {{username}} ja kaikki siihen liittyvät tiedot (isännät, tunnistetiedot, koodinpätkät, historia). Tätä ei voi perua.", + "deleteUserConfirm": "Poistetaanko pysyvästi {{username}} ja kaikki heidän datansa?", + "deleteUserAdminBlocked": "Poista järjestelmänvalvojan tila ennen tämän käyttäjän poistamista.", "createRoleRequired": "Nimi ja näyttönimi vaaditaan", "createRoleSuccess": "Rooli \"{{name}}\" luotu", "createRoleFailed": "Roolin luominen epäonnistui", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Komentohistoria", "hostDefaultsCommandHistoryDesc": "Seuraa komentohistoriaa uusilla isännillä oletuksena", "hostDefaultsSaved": "Isännän oletusasetukset tallennettu", - "hostDefaultsSaveFailed": "Isännän oletusasetusten tallentaminen epäonnistui" + "hostDefaultsSaveFailed": "Isännän oletusasetusten tallentaminen epäonnistui", + "rolePermissions": { + "count": "{{count}} käyttöoikeudet", + "editAction": "Muokkaa käyttöoikeuksia", + "loadError": "Käyttöoikeusluettelon lataaminen epäonnistui", + "saved": "Roolien käyttöoikeudet tallennettu", + "saveError": "Roolien käyttöoikeuksien tallentaminen epäonnistui", + "save": "Tallentaa", + "saving": "Tallennetaan..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Epäonnistui" }, "userProfile": { + "donateTitle": "Pidä Termix hengissä", + "donateDescription": "Termix on ilmainen, itse isännöity ja vain muutaman ihmisen vapaa-ajallaan rakentama palvelu. Jos se on säästänyt aikaasi tai rahaasi, kryptovaluutan lahjoitus auttaa pitämään sen toiminnassa.", + "donateMilestones": "Lahjoitukset auttavat rahoittamaan aikaa SAML:n, Kubernetesin ja agenttituen rakentamiseen tarvittavien resurssien tutkimiseen ja oppimiseen. Katso edistymistä ja lahjoita.", + "donateButton": "Lahjoita kryptovaluuttaa", "storageModeLocal": "Selain", "storageModeCloud": "Tietokanta", "storageModeDescription": "Selain tallentaa asetukset vain tähän selaimeen. Tietokanta synkronoituu palvelimelle ja latautuu mille tahansa laitteelle.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Päällä", "twoFaOff": "Pois", "versionLabel": "Versio", + "betaProgramTitle": "Beetaohjelma", + "betaProgramDescription": "Kokeile tulevia ominaisuuksia etukäteen viikoittain julkaistavan :beta Docker -tunnisteen avulla. Epävakaa, ei tuotantokäyttöön.", + "betaProgramFeedback": "Löysitkö virheen? Ilmoita siitä täällä.", + "betaProgramLearnMore": "Lue lisää", "deleteAccount": "Poista tili", "deleteAccountDescription": "Poista tilisi pysyvästi", "changeServerDescription": "Vaihda toiseen Termix-taustapalvelimeen", diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index 6d850a06..f46614d1 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -1,15 +1,15 @@ { "termixId": { - "title": "Identifiant Termix", + "title": "ID Termix", "loadFailed": "Échec du chargement de l'identifiant Termix", "claimTitle": "Réclamez votre identifiant Termix", "claimIntro": "Choisissez un identifiant unique. Vos clés publiques SSH seront publiées à une URL publique que vous pourrez ajouter au fichier authorized_keys de n'importe quel serveur.", "handleLabel": "Poignée", - "handlePlaceholder": "Alice", + "handlePlaceholder": "alice", "checking": "Vérification…", "available": "Disponible", "taken": "Déjà pris", - "invalidHandle": "Lettres minuscules, chiffres et tirets (-) uniquement.", + "invalidHandle": "Lettres minuscules, chiffres, - et _ seulement", "descriptionLabel": "Description (facultatif)", "descriptionPlaceholder": "Clés de l'ordinateur portable et du téléphone professionnel", "create": "Créer un identifiant Termix", @@ -47,12 +47,12 @@ "linkedToTermixId": "Publié via Termix ID", "selectCredential": "Sélectionnez une qualification...", "import": "Importer", - "caTitle": "autorité de certification", + "caTitle": "Autorité de certification", "caIntro": "Faites confiance à cette autorité de certification sur un serveur : elle acceptera tout certificat que vous signerez. Révoquez-les tous en une seule fois ; les certificats expirent automatiquement.", "caEnable": "Activer l'autorité de certification", "caEnabled": "CA activé", "caCreateFailed": "Échec de l'activation de l'autorité de certification", - "caPublicKeyLabel": "clé publique de l'autorité de certification", + "caPublicKeyLabel": "Clé publique de l'autorité de certification", "caTrustLabel": "Faire confiance à un serveur (exécuté en tant que root)", "caRotate": "Tourner", "caRotateConfirm": "Faut-il faire tourner l'autorité de certification ? Tous les certificats qu'elle a signés cesseront d'être acceptés et devront être réémis.", @@ -64,7 +64,7 @@ "caDeleteFailed": "Échec de la suppression de l'autorité de certification", "caValidityLabel": "Validité du certificat (jours)", "issueCert": "Certificat", - "issueCertTooltip": "Émettez un certificat SSH pour cette clé, signé par votre autorité de certification.", + "issueCertTooltip": "Émettez un certificat SSH pour cette clé, signé par votre autorité de certification", "certIssued": "Certificat délivré et téléchargé", "certIssueFailed": "Échec de l'émission du certificat" }, @@ -111,7 +111,7 @@ "editWidget": "Modifier le widget", "deleteWidget": "Supprimer", "widgetTypes": "Types de widgets", - "serviceLink": "Service Link", + "serviceLink": "Lien vers les services", "folder": "Dossier", "clock": "Horloge", "notes": "Notes", @@ -131,7 +131,7 @@ "widgetTitlePlaceholder": "Titre du widget (facultatif)", "url": "URL", "imageUrl": "URL de l'image personnalisée", - "imageUrlHint": "Laissez ce champ vide pour utiliser automatiquement le favicon du site.", + "imageUrlHint": "Laissez ce champ vide pour utiliser automatiquement le favicon du site", "showImage": "Afficher l'image", "description": "Description", "color": "Couleur", @@ -157,7 +157,7 @@ "widgetFolderDesc": "Un conteneur pour regrouper les widgets associés", "widgetClockDesc": "Une horloge en temps réel avec fuseau horaire configurable", "widgetNotesDesc": "Un widget de notes Markdown", - "widgetHostStatusDesc": "Affiche en temps réel le processeur, la mémoire et le disque d'un hôte SSH.", + "widgetHostStatusDesc": "Affiche en temps réel le processeur, la mémoire et le disque d'un hôte SSH", "widgetBookmarkListDesc": "Liste de liens rapides", "copyLink": "Copier le lien", "linkCopied": "Lien copié !", @@ -181,7 +181,7 @@ "metricsNotAvailable": "Métriques non disponibles", "selectHost": "Sélectionnez un hôte...", "displayedMetrics": "Métriques affichées", - "metricCpu": "processeur", + "metricCpu": "Processeur", "metricMemory": "Mémoire", "metricDisk": "Disque", "metricUptime": "Disponibilité", @@ -269,22 +269,22 @@ "widgetSearchLinksDesc": "Boutons de raccourci de recherche rapide avec saisie intégrée", "widgetLinkTreeName": "Arbre de liens", "widgetLinkTreeDesc": "Sections de liens regroupées avec des titres", - "calMon": "Mo", - "calTue": "Tu", - "calWed": "Nous", - "calThu": "Ème", - "calFri": "Fr", + "calMon": "Lu", + "calTue": "Ma", + "calWed": "Me", + "calThu": "Jeu", + "calFri": "Ve", "calSat": "Sa", - "calSun": "Su", + "calSun": "Di", "startOnMonday": "Début de la semaine lundi", - "countdownNoDate": "Aucune date cible n'a été fixée.", - "countdownPast": "L'événement est passé.", + "countdownNoDate": "Aucune date cible n'a été fixée", + "countdownPast": "L'événement est passé", "countdownDays": "jours", "countdownHours": "heures", "countdownMinutes": "min", "countdownSeconds": "seconde", "countdownLabel": "Étiquette", - "countdownLabelPlaceholder": "par exemple, le jour du lancement", + "countdownLabelPlaceholder": "ex: Le jour du lancement", "countdownShowDays": "Journées d'exposition", "countdownShowHours": "Horaires d'ouverture", "targetDate": "Date cible", @@ -312,7 +312,7 @@ "displayField": "Champ d'affichage", "jsonPath": "Chemin JSON", "customApiLabel": "Étiquette", - "customApiLabelPlaceholder": "par exemple la température", + "customApiLabelPlaceholder": "ex: La température", "customApiUnit": "Unité", "customApiNoUrl": "Aucune URL d'API configurée", "customApiError": "Échec de la récupération", @@ -320,7 +320,7 @@ "addService": "Ajouter un service", "showLabels": "Afficher les étiquettes", "iconSize": "Taille de l'icône", - "noDashboardLinks": "Aucun lien vers le tableau de bord n'est configuré.", + "noDashboardLinks": "Aucun lien vers le tableau de bord n'est configuré", "noLimit": "Aucune limite", "sectionHeading": "Titre de section", "addSection": "Ajouter une section", @@ -368,20 +368,20 @@ }, "serverConfig": { "title": "Configuration du serveur", - "description": "Configurez l'URL du serveur Termix pour vous connecter à vos services backend.", + "description": "Configurez l'URL du serveur Termix pour vous connecter à vos services backend", "serverUrl": "URL du serveur", "enterServerUrl": "Veuillez saisir une URL de serveur", "saveFailed": "Échec de l'enregistrement de la configuration", "saveError": "Erreur lors de l'enregistrement de la configuration", "saving": "Économie...", "saveConfig": "Enregistrer la configuration", - "helpText": "Saisissez l'URL où votre serveur Termix est exécuté (par exemple, http://localhost:30001 ou https://votre-serveur.com).", + "helpText": "Saisissez l'URL où votre serveur Termix est exécuté (par exemple, http://localhost:30001 ou https://votre-serveur.com)", "changeServer": "Changer de serveur", "mustIncludeProtocol": "L'URL du serveur doit commencer par http:// ou https://", "allowInvalidCertificate": "Autoriser un certificat invalide", "allowInvalidCertificateDesc": "À utiliser uniquement pour des serveurs auto-hébergés de confiance avec des certificats auto-signés ou des certificats d'adresse IP.", "useEmbedded": "Utiliser le serveur local", - "embeddedDesc": "Exécutez Termix avec le serveur local intégré (aucun serveur distant n'est nécessaire).", + "embeddedDesc": "Exécutez Termix avec le serveur local intégré (aucun serveur distant n'est nécessaire)", "embeddedConnecting": "Connexion au serveur local...", "embeddedNotReady": "Le serveur local n'est pas encore prêt. Veuillez patienter quelques instants et réessayer.", "localServer": "Serveur local", @@ -600,17 +600,17 @@ "selectSnippet": "Sélectionner un extrait", "forceKeyboardInteractive": "Forcer l'interaction au clavier", "overrideCredentialUsername": "Remplacer le nom d'utilisateur d'identification", - "overrideCredentialUsernameDesc": "Utilisez le nom d'utilisateur indiqué ci-dessus au lieu du nom d'utilisateur des identifiants.", + "overrideCredentialUsernameDesc": "Utilisez le nom d'utilisateur indiqué ci-dessus au lieu du nom d'utilisateur des identifiants", "oidcUsernameHint": "Utilisez $oidc.preferred_username pour remplacer votre nom de connexion OIDC.", "jumpHostChain": "Chaîne d'hôtes de saut", - "portKnocking": "Port Knocking", + "portKnocking": "Tocage à la Porte", "addKnock": "Ajouter un port", "addProxyNode": "Ajouter un nœud", "proxyNode": "Nœud proxy", "proxyType": "Type de proxy", "quickActions": "Actions rapides", - "sudoPasswordAutoFill": "Remplissage automatique du mot de passe Sudo", - "sudoPassword": "Mot de passe Sudo", + "sudoPasswordAutoFill": "Remplissage automatique du mot de passe sudo", + "sudoPassword": "Mot de passe sudo", "keepaliveInterval": "Intervalle de maintien de connexion (ms)", "moshCommand": "Commandement MOSH", "environmentVariables": "Variables environnementales", @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Modèles de noms séparés par des virgules permettant à Docker d'identifier les invités", "proxmoxPreferredRanges": "Plages d'adresses IP préférées", "proxmoxPreferredRangesDesc": "Préfixes séparés par des virgules, classés par ordre de priorité pour la sélection d'adresses IP lorsqu'un invité possède plusieurs interfaces.", + "proxmoxAutoSync": "Synchronisation automatique des invités", + "proxmoxAutoSyncDesc": "Découvrez périodiquement ce nœud Proxmox et créez ou mettez à jour les hôtes invités importés pendant que votre session est déverrouillée.", + "proxmoxSyncInterval": "Intervalle de synchronisation (minutes)", + "proxmoxSyncIntervalDesc": "Minimum 5 minutes. Le planificateur ignore les sessions utilisateur verrouillées.", + "proxmoxMarkMissing": "Marquer les invités manquants", + "proxmoxMarkMissingDesc": "Ajoutez une étiquette proxmox-missing lorsqu'un invité précédemment importé disparaît, au lieu de le supprimer.", + "proxmoxLastSync": "Dernière synchronisation", + "proxmoxLastSyncNever": "Pas encore synchronisé", + "proxmoxLastSyncNoResult": "Aucun résultat détaillé", + "proxmoxLastSyncSummary": "{{created}} créé, {{updated}} mis à jour, {{markedMissing}} manquant, {{skipped}} ignoré", + "proxmoxLastSyncStatus": { + "success": "Succès", + "error": "Échoué", + "pending": "En attente" + }, "proxmoxDiscoverAction": "Découvrir et importer des invités Proxmox", "proxmoxImportTitle": "Importer depuis Proxmox", "proxmoxSelectHost": "Sélectionnez un hôte Proxmox…", @@ -1045,7 +1060,7 @@ "deployingBtn": "Déploiement...", "deployBtn": "Déployer", "failedToDeployKey": "Échec du déploiement de la clé", - "deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.", + "deleteHostsConfirm": "Supprimer {{count}} hôte{{plural}} ? Cette opération est irréversible.", "movedToRoot": "Déplacé vers la racine", "enableTerminalFeature": "Activer le terminal", "disableTerminalFeature": "Désactiver le terminal", @@ -1167,7 +1182,57 @@ "shareHost": "Hôte partagé", "shareHostTitle": "Partager : {{name}}", "sharing": { - "requiresCredential": "Cet hôte doit utiliser des identifiants pour activer le partage. Modifiez l'hôte et attribuez-lui d'abord des identifiants." + "loadError": "Impossible de charger les données de partage. Veuillez réessayer.", + "shareWithSection": "Partager avec", + "usersTab": "Utilisateurs", + "rolesTab": "Rôles", + "searchPlaceholder": "Rechercher des utilisateurs ou des rôles...", + "noMatches": "Aucun résultat trouvé", + "permissionLevelLabel": "Niveau d'autorisation", + "levels": { + "connect": { + "label": "Connecter", + "description": "Seules les sessions ouvertes (terminal, bureau à distance, gestionnaire de fichiers, tunnels et Docker) sont autorisées. Aucun accès à la configuration de l'hôte n'est autorisé." + }, + "view": { + "label": "Voir", + "description": "Connectez-vous et consultez la configuration de l'hôte. Les secrets ne sont jamais divulgués." + }, + "edit": { + "label": "Modifier", + "description": "Consultez et modifiez l'hôte. Les secrets peuvent être remplacés mais jamais lus ; les identifiants restent réservés à leur propriétaire." + }, + "manage": { + "label": "Gérer", + "description": "Modifiez, partagez l'hôte avec d'autres, changez les niveaux d'autorisation et révoquer l'accès." + } + }, + "expiryLabel": "Expiration de l'accès", + "expiry": { + "never": "Jamais", + "oneHour": "1 heure", + "oneDay": "24 heures", + "sevenDays": "7 jours", + "thirtyDays": "30 jours", + "custom": "Coutume" + }, + "customHoursPlaceholder": "Heures restantes avant l'expiration de l'accès", + "shareButton": "Partager", + "shareWithCount": "Partager ({{count}})", + "currentAccess": "Accès actuel", + "noAccessEntries": "Cet hôte n'a pas encore été partagé.", + "grantedBy": "Accordé par", + "expires": "Expire", + "expired": "Expiré", + "never": "Jamais", + "revoke": "Révoquer", + "accessUpdated": "Accès mis à jour", + "accessUpdateFailed": "Échec de la mise à jour de l'accès", + "sharedBadge": "Commun", + "sharedBadgeTooltip": "Partagé par {{owner}} ({{level}} accès)", + "viewOnlyBanner": "Cet hôte est partagé avec vous par {{owner}} qui dispose d'un accès en lecture seule. La configuration est en lecture seule.", + "sharedEditBanner": "Cet hôte est partagé avec vous par {{owner}} qui dispose d'un accès en modification. Les modifications s'appliquent à l'hôte réel ; les références d'authentification ne peuvent être modifiées que par le propriétaire.", + "ownerOnlyControl": "Seul le propriétaire de l'hôte peut modifier cela." }, "guac": { "connection": "Connexion", @@ -1302,28 +1367,10 @@ "backspaceKey": "Touche Retour arrière", "saveHostFirst": "Sauvegardez d'abord l'hôte.", "sharingOptionsAfterSave": "Les options de partage sont disponibles une fois l'hôte enregistré.", - "sharingLoadError": "Impossible de charger les données de partage. Vérifiez votre connexion et réessayez.", - "shareHostSection": "Hôte partagé", - "shareWithUser": "Partager avec l'utilisateur", - "shareWithRole": "Partager avec le rôle", - "selectUser": "Sélectionner l'utilisateur", - "selectRole": "Sélectionner un rôle", - "selectUserOption": "Sélectionnez un utilisateur...", - "selectRoleOption": "Choisissez un rôle...", "permissionLevel": "Niveau d'autorisation", - "expiresInHours": "Expire dans (heures)", - "noExpiryPlaceholder": "Laisser vide pour une durée de conservation illimitée.", - "shareBtn": "Partager", - "currentAccess": "Accès actuel", "typeHeader": "Taper", "targetHeader": "Cible", "permissionHeader": "Autorisation", - "grantedByHeader": "Accordé par", - "expiresHeader": "Expire", - "noAccessEntries": "Aucune entrée d'accès pour le moment.", - "expiredLabel": "Expiré", - "neverLabel": "Jamais", - "revokeBtn": "Révoquer", "cancelBtn": "Annuler", "savingBtn": "Économie...", "updateHostBtn": "Mettre à jour l'hôte", @@ -1693,7 +1740,7 @@ "dragOutsideToDownload": "Faites glisser en dehors de la fenêtre pour télécharger ({{count}} fichiers)", "newFolderDefault": "Nouveau dossier", "newFileDefault": "NouveauFichier.txt", - "successfullyMovedItems": "Successfully moved {{count}} items to {{target}}", + "successfullyMovedItems": "Les objets {{count}} ont été déplacés vers {{target}} avec succès", "move": "Se déplacer", "searchInFile": "Rechercher dans un fichier (Ctrl+F)", "showKeyboardShortcuts": "Afficher les raccourcis clavier", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "La connexion par mot de passe est désactivée. Veuillez utiliser une clé d'accès ou un fournisseur d'authentification externe.", "signInWithPasskey": "Connectez-vous avec votre clé d'accès", "passkeyLoginFailed": "Échec de la connexion par mot de passe", - "attemptsRemaining": "{{count}} tentatives restantes" + "attemptsRemaining": "{{count}} tentatives restantes", + "confirmResetDataWipe": "Ce compte n'a pas été utilisé depuis la mise à jour du chiffrement ; ses données ne peuvent donc pas être récupérées sans l'ancien mot de passe. La réinitialisation supprimera définitivement ses hôtes, identifiants et extraits de code. Continuer ?" }, "hostKey": { "verifyNewHost": "Vérifier la clé d'hôte SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Liens de service", "homepagePreview": "Aperçu de la page d'accueil" }, + "donation": { + "title": "Vous appréciez Termix ?", + "body": "Termix est un logiciel libre et gratuit, développé et maintenu par une équipe de deux personnes sur leur temps libre. S'il remplace un outil commercial que vous auriez autrement acheté, un don contribue à couvrir les frais d'hébergement et à soutenir son développement. Pour l'instant, les dons sont uniquement acceptés en cryptomonnaie.", + "milestones": "Les dons permettent également de financer le temps consacré à la recherche et à l'apprentissage des compétences nécessaires au développement de fonctionnalités telles que la prise en charge de SAML et de Kubernetes. Suivez l'avancement du projet sur la page de don.", + "cta": "Faire un don", + "dismiss": "Peut-être plus tard" + }, "dashboardTab": { "stable": "ÉCURIE", "hostsOnline": "Hôtes en ligne", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Échec de la mise à jour du statut d'administrateur", "allSessionsRevoked": "Toutes les sessions ont été révoquées.", "revokeSessionsFailed": "Échec de la révocation des sessions", + "manageUserData": "Gérer les données utilisateur", + "backToUsers": "Retour aux utilisateurs", + "manageTabAccount": "Compte", + "manageTabHosts": "Hôtes", + "manageTabCredentials": "Informations d'identification", + "manageTabSnippets": "Extraits", + "manageTabSessions": "Séances", + "manageTabDanger": "Danger", + "manageEditorBack": "Retour aux données de {{username}}", + "dataLockedBadge": "FERMÉ", + "dataLockedNotice": "Les données de {{username}}restent verrouillées jusqu'à sa prochaine connexion. Ses hôtes, identifiants et extraits de code ne peuvent être consultés ni modifiés d'ici là.", + "resetPasswordTitle": "Réinitialiser le mot de passe", + "resetPasswordOidcOnly": "Cet utilisateur se connecte via un fournisseur externe et n'a pas de mot de passe.", + "resetPasswordPlaceholder": "Nouveau mot de passe", + "resetPasswordBtn": "Réinitialiser", + "resetPasswordWorking": "Réinitialisation...", + "resetPasswordSuccess": "Réinitialisation du mot de passe", + "resetPasswordSuccessWiped": "Réinitialisation du mot de passe. Les données chiffrées de l'utilisateur ont été effacées.", + "resetPasswordFailed": "Échec de la réinitialisation du mot de passe", + "resetPasswordConfirmWipe": "L'utilisateur {{username}} ne s'est pas connecté depuis la mise à jour du chiffrement ; ses données sont donc irrécupérables. La réinitialisation effacera ses hôtes, ses identifiants et ses extraits de code. Continuer ?", + "totpSectionTitle": "Authentification à deux facteurs", + "totpStatusEnabled": "Le protocole TOTP est activé pour cet utilisateur.", + "totpStatusDisabled": "Le protocole TOTP n'est pas activé pour cet utilisateur.", + "disableTotp": "Désactiver", + "disableTotpConfirm": "Désactiver l'authentification à deux facteurs pour {{username}}? Ils pourront se connecter avec leur seul mot de passe.", + "totpDisabledSuccess": "TOTP désactivé", + "totpDisableFailed": "Échec de la désactivation du TOTP", + "manageApiKeys": "Clés API", + "apiKeyNamePlaceholder": "Nom de la clé", + "apiKeyCopyNotice": "Copiez cette clé maintenant, elle ne sera plus affichée.", + "noApiKeysForUser": "Aucune clé API", + "apiKeyDeleteFailed": "Échec de la suppression de la clé API", + "exportUserData": "Exportation de données", + "exportUserDataDesc": "Téléchargez les données d'hôtes, d'identifiants et de gestionnaire de fichiers de cet utilisateur au format JSON. Les secrets sont déchiffrés.", + "exportUserDataSuccess": "Données utilisateur exportées", + "exportUserDataFailed": "Échec de l'exportation des données utilisateur", + "hostsCount": "{{count}} hôtes", + "addHostForUser": "Ajouter un hôte", + "noHostsForUser": "Cet utilisateur n'a pas d'hôtes.", + "connectToHost": "Connecter", + "deleteHostConfirm": "Supprimer l'hôte \"{{name}}\" appartenant à {{username}}?", + "hostDeletedSuccess": "Hôte supprimé", + "hostDeleteFailed": "Échec de la suppression de l'hôte", + "credentialsCount": "{{count}} identifiants", + "addCredentialForUser": "Ajouter un identifiant", + "noCredentialsForUser": "Cet utilisateur ne possède aucun identifiant", + "deleteCredentialConfirm": "Supprimer l'identifiant «{{name}}» appartenant à {{username}}?", + "credentialDeletedSuccess": "Identifiants supprimés", + "credentialDeleteFailed": "Échec de la suppression des identifiants", + "snippetsCount": "{{count}} extraits", + "addSnippetForUser": "Ajouter un extrait", + "noSnippetsForUser": "Cet utilisateur n'a aucun extrait.", + "snippetRequiredFields": "Le nom et le contenu de l'extrait sont obligatoires.", + "snippetNamePlaceholder": "Nom de l'extrait", + "snippetContentPlaceholder": "Contenu de la commande", + "snippetFolderPlaceholder": "Dossier (facultatif)", + "snippetSaved": "Extrait enregistré", + "snippetSaveFailed": "Impossible d'enregistrer l'extrait.", + "deleteSnippetConfirm": "Supprimer l'extrait \"{{name}}\" appartenant à {{username}}?", + "snippetDeletedSuccess": "Extrait supprimé", + "snippetDeleteFailed": "Échec de la suppression de l'extrait", + "noSessionsForUser": "Aucune session active", + "deleteUserDangerDesc": "Supprimer définitivement {{username}} et toutes ses données (hôtes, identifiants, extraits, historique). Cette action est irréversible.", + "deleteUserConfirm": "Supprimer définitivement {{username}} et toutes ses données ?", + "deleteUserAdminBlocked": "Supprimez le statut d'administrateur avant de supprimer cet utilisateur.", "createRoleRequired": "Le nom et le nom d'affichage sont obligatoires.", "createRoleSuccess": "Rôle «{{name}}» créé", "createRoleFailed": "Échec de la création du rôle", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Historique des commandes", "hostDefaultsCommandHistoryDesc": "Suivi de l'historique des commandes sur les nouveaux hôtes par défaut", "hostDefaultsSaved": "Paramètres par défaut de l'hôte enregistrés", - "hostDefaultsSaveFailed": "Impossible d'enregistrer les paramètres par défaut de l'hôte." + "hostDefaultsSaveFailed": "Impossible d'enregistrer les paramètres par défaut de l'hôte.", + "rolePermissions": { + "count": "{{count}} permissions", + "editAction": "Modifier les autorisations", + "loadError": "Échec du chargement du catalogue des autorisations", + "saved": "Les autorisations de rôle ont été enregistrées", + "saveError": "Impossible d'enregistrer les autorisations de rôle.", + "save": "Sauvegarder", + "saving": "Économie..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Échoué" }, "userProfile": { + "donateTitle": "Maintenez Termix en vie", + "donateDescription": "Termix est gratuit, auto-hébergé et développé par une poignée de personnes sur leur temps libre. Si ce logiciel vous a permis d'économiser du temps ou de l'argent, un don en cryptomonnaie nous aidera à poursuivre son développement.", + "donateMilestones": "Vos dons nous permettent de financer le temps consacré à la recherche et à l'apprentissage des compétences nécessaires au développement de la prise en charge de SAML, Kubernetes et des agents. Suivez l'avancement du projet et faites un don.", + "donateButton": "Faire un don en cryptomonnaie", "storageModeLocal": "Navigateur", "storageModeCloud": "Base de données", "storageModeDescription": "Le navigateur enregistre les paramètres uniquement dans ce navigateur. La base de données est synchronisée avec le serveur et accessible depuis n'importe quel appareil.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Sur", "twoFaOff": "Désactivé", "versionLabel": "Version", + "betaProgramTitle": "Programme bêta", + "betaProgramDescription": "Testez les nouvelles fonctionnalités en avant-première grâce à la balise Docker bêta (mise à jour hebdomadaire). Instable, ne convient pas à la production.", + "betaProgramFeedback": "Vous avez trouvé un bug ? Signalez-le ici.", + "betaProgramLearnMore": "Apprendre encore plus", "deleteAccount": "Supprimer le compte", "deleteAccountDescription": "Supprimer définitivement votre compte", "changeServerDescription": "Passer à un autre serveur backend Termix", diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 03b5b86f..0db4e466 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "תבניות שמות מופרדות בפסיקים המאפשרות ל-Docker להתאים אורחים", "proxmoxPreferredRanges": "טווחי IP מועדפים", "proxmoxPreferredRangesDesc": "קידומות מופרדות בפסיקים בסדר עדיפות לבחירת IP כאשר לאורח יש ממשקים מרובים", + "proxmoxAutoSync": "סנכרון אוטומטי של אורחים", + "proxmoxAutoSyncDesc": "גלה מעת לעת את צומת Proxmox הזה וצור או עדכן מארחי אורחים מיובאים בזמן שההפעלה שלך אינה נעולה.", + "proxmoxSyncInterval": "מרווח סנכרון (דקות)", + "proxmoxSyncIntervalDesc": "מינימום 5 דקות. המתזמן מדלג על סשנים נעולים של משתמשים.", + "proxmoxMarkMissing": "סמן אורחים חסרים", + "proxmoxMarkMissingDesc": "הוסף תג proxmox-missing כאשר אורח שיובא בעבר נעלם במקום למחוק אותו.", + "proxmoxLastSync": "סנכרון אחרון", + "proxmoxLastSyncNever": "עדיין לא מסונכרן", + "proxmoxLastSyncNoResult": "אין פרטי תוצאה", + "proxmoxLastSyncSummary": "{{created}} נוצר, {{updated}} עודכן, {{markedMissing}} חסר, {{skipped}} דילג", + "proxmoxLastSyncStatus": { + "success": "הַצלָחָה", + "error": "נִכשָׁל", + "pending": "תָלוּי וְעוֹמֵד" + }, "proxmoxDiscoverAction": "גלה וייבא אורחי Proxmox", "proxmoxImportTitle": "ייבוא מ-Proxmox", "proxmoxSelectHost": "בחר מארח Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "שתף מארח", "shareHostTitle": "שתף: {{name}}", "sharing": { - "requiresCredential": "מארח זה חייב להשתמש באישור כדי לאפשר שיתוף. ערוך את המארח והקצה אישור תחילה." + "loadError": "טעינת נתוני השיתוף נכשלה. אנא נסה שוב.", + "shareWithSection": "שתף עם", + "usersTab": "משתמשים", + "rolesTab": "תפקידים", + "searchPlaceholder": "חיפוש משתמשים או תפקידים...", + "noMatches": "לא נמצאו התאמות", + "permissionLevelLabel": "רמת הרשאה", + "levels": { + "connect": { + "label": "לְחַבֵּר", + "description": "הפעלות פתוחות בלבד: טרמינל, שולחן עבודה מרוחק, מנהל קבצים, מנהרות ו-Docker. אין גישה לתצורת המארח." + }, + "view": { + "label": "נוֹף", + "description": "התחבר, בנוסף ראה את תצורת המארח. סודות לעולם אינם מוצגים." + }, + "edit": { + "label": "לַעֲרוֹך", + "description": "הצג ושנה את המארח. ניתן להחליף סודות אך לעולם לא לקרוא אותם; הקצאות אישורים נשארות לבעלים בלבד." + }, + "manage": { + "label": "לְנַהֵל", + "description": "עריכה, בנוסף לשיתוף המארח עם אחרים, שינוי רמות הרשאה וביטול גישה." + } + }, + "expiryLabel": "פקיעת גישה", + "expiry": { + "never": "לְעוֹלָם לֹא", + "oneHour": "שעה אחת", + "oneDay": "24 שעות", + "sevenDays": "7 ימים", + "thirtyDays": "30 ימים", + "custom": "מִנְהָג" + }, + "customHoursPlaceholder": "שעות עד לפקיעת הגישה", + "shareButton": "לַחֲלוֹק", + "shareWithCount": "שתף ({{count}})", + "currentAccess": "גישה נוכחית", + "noAccessEntries": "מארח זה עדיין לא שותף", + "grantedBy": "הוענק על ידי", + "expires": "פג תוקף", + "expired": "פג תוקף", + "never": "לְעוֹלָם לֹא", + "revoke": "לְבַטֵל", + "accessUpdated": "הגישה עודכנה", + "accessUpdateFailed": "עדכון הגישה נכשל", + "sharedBadge": "מְשׁוּתָף", + "sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה ל-{{level}})", + "viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישת צפייה. התצורה היא לקריאה בלבד.", + "sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישת עריכה. השינויים חלים על המארח האמיתי; רק הבעלים יכול לשנות הפניות לאימות.", + "ownerOnlyControl": "רק בעל המארח יכול לשנות זאת" }, "guac": { "connection": "קֶשֶׁר", @@ -1302,28 +1367,10 @@ "backspaceKey": "מקש Backspace", "saveHostFirst": "שמור את המארח קודם.", "sharingOptionsAfterSave": "אפשרויות שיתוף זמינות לאחר שמירת המארח.", - "sharingLoadError": "טעינת נתוני השיתוף נכשלה. בדוק את החיבור ונסה שוב.", - "shareHostSection": "שתף מארח", - "shareWithUser": "שתף עם משתמש", - "shareWithRole": "שתף עם תפקיד", - "selectUser": "בחר משתמש", - "selectRole": "בחר תפקיד", - "selectUserOption": "בחר משתמש...", - "selectRoleOption": "בחר תפקיד...", "permissionLevel": "רמת הרשאה", - "expiresInHours": "פג תוקף בעוד (שעות)", - "noExpiryPlaceholder": "השאר ריק ללא תפוגה", - "shareBtn": "לַחֲלוֹק", - "currentAccess": "גישה נוכחית", "typeHeader": "סוּג", "targetHeader": "יַעַד", "permissionHeader": "רְשׁוּת", - "grantedByHeader": "הוענק על ידי", - "expiresHeader": "פג תוקף", - "noAccessEntries": "עדיין אין רשומות גישה.", - "expiredLabel": "פג תוקף", - "neverLabel": "לְעוֹלָם לֹא", - "revokeBtn": "לְבַטֵל", "cancelBtn": "לְבַטֵל", "savingBtn": "חִסָכוֹן...", "updateHostBtn": "עדכון מארח", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "כניסה באמצעות סיסמה מושבתת. השתמש בסיסמה או בספק אימות חיצוני.", "signInWithPasskey": "כניסה עם סיסמה", "passkeyLoginFailed": "כניסה עם סיסמה נכשלה", - "attemptsRemaining": "{{count}} ניסיונות נותרו" + "attemptsRemaining": "{{count}} ניסיונות נותרו", + "confirmResetDataWipe": "חשבון זה לא התחבר מאז שדרוג ההצפנה, כך שלא ניתן לשחזר את הנתונים המאוחסנים בו ללא הסיסמה הישנה. איפוס ימחק לצמיתות את המארחים, האישורים והקטעים שלו. האם להמשיך?" }, "hostKey": { "verifyNewHost": "אימות מפתח מארח SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "קישורי שירות", "homepagePreview": "תצוגה מקדימה של דף הבית" }, + "donation": { + "title": "נהנים מ-Termix?", + "body": "Termix הוא כלי חינמי וקוד פתוח, שנבנה ומתוחזק על ידי צוות של שני אנשים בזמננו הפנוי. אם הוא מחליף כלי מסחרי שהייתם משלמים עליו אחרת, תרומה מסייעת לכסות את עלויות האירוח ולשמור על הפיתוח מתמשך. נכון לעכשיו, תרומות הן קריפטו בלבד.", + "milestones": "תרומות גם עוזרות לממן את הזמן הנדרש למחקר וללמידה של מה שנדרש כדי לבנות תכונות כמו SAML ותמיכה ב-Kubernetes. ראו את ההתקדמות בדף התרומות.", + "cta": "לִתְרוֹם", + "dismiss": "אולי מאוחר יותר" + }, "dashboardTab": { "stable": "יַצִיב", "hostsOnline": "מארחים אונליין", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "נכשל עדכון סטטוס מנהל המערכת", "allSessionsRevoked": "כל הפעילויות בוטלו", "revokeSessionsFailed": "נכשלה ביטול ההפעלות", + "manageUserData": "ניהול נתוני משתמש", + "backToUsers": "חזרה למשתמשים", + "manageTabAccount": "חֶשְׁבּוֹן", + "manageTabHosts": "מארחים", + "manageTabCredentials": "אישורים", + "manageTabSnippets": "קטעי טקסט", + "manageTabSessions": "מפגשים", + "manageTabDanger": "סַכָּנָה", + "manageEditorBack": "חזרה לנתונים של {{username}}", + "dataLockedBadge": "נָעוּל", + "dataLockedNotice": "הנתונים של {{username}}נשארים נעולים עד לכניסה הבאה שלהם. לא ניתן יהיה לצפות או לערוך את המארחים, האישורים והקטעים שלהם עד אז.", + "resetPasswordTitle": "איפוס סיסמה", + "resetPasswordOidcOnly": "משתמש זה מתחבר דרך ספק חיצוני ואין לו סיסמה.", + "resetPasswordPlaceholder": "סיסמה חדשה", + "resetPasswordBtn": "אִתחוּל", + "resetPasswordWorking": "איפוס...", + "resetPasswordSuccess": "איפוס סיסמה", + "resetPasswordSuccessWiped": "איפוס סיסמה. הנתונים המוצפנים של המשתמש נמחקו.", + "resetPasswordFailed": "איפוס הסיסמה נכשל", + "resetPasswordConfirmWipe": "{{username}} לא התחבר מאז שדרוג ההצפנה, כך שלא ניתן לשחזר את הנתונים שלו. איפוס כעת ימחק את המארחים, האישורים והקטעים שלו. להמשיך?", + "totpSectionTitle": "אימות דו-שלבי", + "totpStatusEnabled": "TOTP מופעל עבור משתמש זה", + "totpStatusDisabled": "TOTP אינו מופעל עבור משתמש זה", + "disableTotp": "השבת", + "disableTotpConfirm": "להשבית אימות דו-שלבי עבור {{username}}? הם יוכלו להתחבר רק עם הסיסמה שלהם.", + "totpDisabledSuccess": "TOTP מושבת", + "totpDisableFailed": "נכשל בהשבתת TOTP", + "manageApiKeys": "מפתחות API", + "apiKeyNamePlaceholder": "שם המפתח", + "apiKeyCopyNotice": "העתק את המפתח הזה עכשיו, הוא לא יוצג שוב.", + "noApiKeysForUser": "אין מפתחות API", + "apiKeyDeleteFailed": "נכשלה מחיקת מפתח ה-API", + "exportUserData": "ייצוא נתונים", + "exportUserDataDesc": "הורד את המארחים, האישורים ונתוני מנהל הקבצים של משתמש זה כ-JSON. הסודות מפוענחים.", + "exportUserDataSuccess": "נתוני משתמש יוצאו", + "exportUserDataFailed": "ייצוא נתוני המשתמש נכשל", + "hostsCount": "{{count}} hosts", + "addHostForUser": "הוסף מארח", + "noHostsForUser": "למשתמש זה אין מארחים", + "connectToHost": "לְחַבֵּר", + "deleteHostConfirm": "למחוק את המארח \"{{name}}\" השייך ל- {{username}}?", + "hostDeletedSuccess": "המארח נמחק", + "hostDeleteFailed": "מחיקת המארח נכשלה", + "credentialsCount": "אישורים {{count}}", + "addCredentialForUser": "הוסף אישור", + "noCredentialsForUser": "למשתמש זה אין אישורים", + "deleteCredentialConfirm": "למחוק את פרטי הכניסה \"{{name}}\" השייכים ל- {{username}}?", + "credentialDeletedSuccess": "פרטי הכניסה נמחקו", + "credentialDeleteFailed": "מחיקת האישורים נכשלה", + "snippetsCount": "קטעי טקסט {{count}}", + "addSnippetForUser": "הוסף קטע", + "noSnippetsForUser": "למשתמש זה אין קטעי טקסט", + "snippetRequiredFields": "שם ותוכן הקטע נדרשים", + "snippetNamePlaceholder": "שם קטע", + "snippetContentPlaceholder": "תוכן הפקודה", + "snippetFolderPlaceholder": "תיקייה (אופציונלי)", + "snippetSaved": "קטע הטקסט נשמר", + "snippetSaveFailed": "שמירת קטע הקוד נכשלה", + "deleteSnippetConfirm": "למחוק את קטע הקוד \"{{name}}\" השייך ל- {{username}}?", + "snippetDeletedSuccess": "קטע הטקסט נמחק", + "snippetDeleteFailed": "מחיקת קטע הקוד נכשלה", + "noSessionsForUser": "אין סשנים פעילים", + "deleteUserDangerDesc": "מחק לצמיתות את {{username}} ואת כל הנתונים שלו (מארחים, אישורים, קטעי קוד, היסטוריה). לא ניתן לבטל פעולה זו.", + "deleteUserConfirm": "למחוק לצמיתות את {{username}} ואת כל הנתונים שלו?", + "deleteUserAdminBlocked": "הסר את סטטוס המנהל לפני מחיקת משתמש זה.", "createRoleRequired": "חובה להזין שם ושם תצוגה", "createRoleSuccess": "התפקיד \"{{name}}\" נוצר", "createRoleFailed": "יצירת התפקיד נכשלה", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "היסטוריית פקודות", "hostDefaultsCommandHistoryDesc": "מעקב אחר היסטוריית פקודות במחשבים חדשים כברירת מחדל", "hostDefaultsSaved": "ברירות מחדל של המארח נשמרו", - "hostDefaultsSaveFailed": "נכשלה שמירת ברירות המחדל של המארח" + "hostDefaultsSaveFailed": "נכשלה שמירת ברירות המחדל של המארח", + "rolePermissions": { + "count": "הרשאות {{count}}", + "editAction": "עריכת הרשאות", + "loadError": "נכשלה טעינת קטלוג ההרשאות", + "saved": "הרשאות התפקיד נשמרו", + "saveError": "שמירת הרשאות התפקיד נכשלה", + "save": "לְהַצִיל", + "saving": "חִסָכוֹן..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "נִכשָׁל" }, "userProfile": { + "donateTitle": "לשמור על טרמיקס בחיים", + "donateDescription": "Termix הוא שירות חינמי, מתארח בעצמו, ונבנה על ידי מספר אנשים בלבד בזמנם הפנוי. אם זה חסך לכם זמן או כסף, תרומת קריפטו תעזור לשמור עליו.", + "donateMilestones": "תרומות עוזרות לממן את הזמן הנדרש למחקר וללמוד מה נדרש כדי לבנות תמיכה ב-SAML, Kubernetes ו-Agent. צפו בהתקדמות ותרמו.", + "donateButton": "תרום קריפטו", "storageModeLocal": "דפדפן", "storageModeCloud": "מסד נתונים", "storageModeDescription": "הדפדפן שומר הגדרות בדפדפן זה בלבד. מסד הנתונים מסתנכרן עם השרת ונטען בכל מכשיר.", @@ -3011,6 +3144,10 @@ "twoFaOn": "עַל", "twoFaOff": "כבוי", "versionLabel": "גִרְסָה", + "betaProgramTitle": "תוכנית בטא", + "betaProgramDescription": "נסה תכונות עתידיות מוקדם עם תגית Docker המתגלגלת השבועית :beta. לא יציב, לא מיועד לייצור.", + "betaProgramFeedback": "מצאת באג? דווח על זה כאן.", + "betaProgramLearnMore": "למידע נוסף", "deleteAccount": "מחיקת חשבון", "deleteAccountDescription": "מחיקת החשבון שלך לצמיתות", "changeServerDescription": "מעבר לשרת אחורי אחר של Termix", diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index 98038a31..4d88b186 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "कॉमा-सेपरेटेड नाम पैटर्न जो डॉकर को गेस्ट्स से मिलान करने में सक्षम बनाते हैं", "proxmoxPreferredRanges": "पसंदीदा आईपी रेंज", "proxmoxPreferredRangesDesc": "अतिथि कंप्यूटर में एकाधिक इंटरफेस होने पर आईपी चयन के लिए अल्पविराम से अलग किए गए उपसर्गों को प्राथमिकता क्रम में रखा जाता है।", + "proxmoxAutoSync": "अतिथियों के साथ स्वतः सिंक", + "proxmoxAutoSyncDesc": "समय-समय पर इस प्रॉक्समॉक्स नोड का पता लगाएं और जब आपका सत्र अनलॉक हो, तब आयातित अतिथि होस्ट बनाएं या अपडेट करें।", + "proxmoxSyncInterval": "सिंक्रोनाइज़ेशन अंतराल (मिनटों में)", + "proxmoxSyncIntervalDesc": "कम से कम 5 मिनट। शेड्यूलर लॉक किए गए उपयोगकर्ता सत्रों को छोड़ देता है।", + "proxmoxMarkMissing": "अनुपस्थित मेहमानों को चिह्नित करें", + "proxmoxMarkMissingDesc": "पहले से आयात किए गए गेस्ट के गायब हो जाने पर, उसे हटाने के बजाय, एक proxmox-missing टैग जोड़ें।", + "proxmoxLastSync": "अंतिम सिंक", + "proxmoxLastSyncNever": "अभी तक सिंक नहीं हुआ है", + "proxmoxLastSyncNoResult": "कोई परिणाम विवरण उपलब्ध नहीं है", + "proxmoxLastSyncSummary": "{{created}} बनाया गया, {{updated}} अपडेट किया गया, {{markedMissing}} अनुपस्थित, {{skipped}} छोड़ा गया", + "proxmoxLastSyncStatus": { + "success": "सफलता", + "error": "असफल", + "pending": "लंबित" + }, "proxmoxDiscoverAction": "Proxmox गेस्ट्स को खोजें और इंपोर्ट करें", "proxmoxImportTitle": "Proxmox से आयात करें", "proxmoxSelectHost": "एक Proxmox होस्ट चुनें…", @@ -1167,7 +1182,57 @@ "shareHost": "होस्ट साझा करें", "shareHostTitle": "शेयर करें: {{name}}", "sharing": { - "requiresCredential": "इस होस्ट को शेयरिंग सक्षम करने के लिए क्रेडेंशियल का उपयोग करना होगा। पहले होस्ट को संपादित करें और एक क्रेडेंशियल असाइन करें।" + "loadError": "शेयरिंग डेटा लोड करने में विफल। कृपया पुनः प्रयास करें।", + "shareWithSection": "के साथ शेयर करें", + "usersTab": "उपयोगकर्ताओं", + "rolesTab": "भूमिकाएँ", + "searchPlaceholder": "उपयोगकर्ताओं या भूमिकाओं को खोजें...", + "noMatches": "कोई मेल नहीं मिले", + "permissionLevelLabel": "अनुमति स्तर", + "levels": { + "connect": { + "label": "जोड़ना", + "description": "केवल टर्मिनल, रिमोट डेस्कटॉप, फ़ाइल मैनेजर, टनल और डॉकर के लिए ही ओपन सेशन उपलब्ध हैं। होस्ट कॉन्फ़िगरेशन तक कोई पहुंच नहीं है।" + }, + "view": { + "label": "देखना", + "description": "कनेक्ट करें और होस्ट कॉन्फ़िगरेशन देखें। गुप्त जानकारी कभी प्रदर्शित नहीं की जाती है।" + }, + "edit": { + "label": "संपादन करना", + "description": "होस्ट को देखें और संशोधित करें। गुप्त जानकारी को बदला जा सकता है लेकिन पढ़ा नहीं जा सकता; क्रेडेंशियल असाइनमेंट केवल मालिक के पास ही रहते हैं।" + }, + "manage": { + "label": "प्रबंधित करना", + "description": "होस्ट को संपादित करें, साथ ही दूसरों के साथ साझा करें, अनुमति स्तर बदलें और पहुंच रद्द करें।" + } + }, + "expiryLabel": "पहुँच समाप्ति", + "expiry": { + "never": "कभी नहीं", + "oneHour": "1 घंटा", + "oneDay": "24 घंटे", + "sevenDays": "7 दिन", + "thirtyDays": "30 दिन", + "custom": "रिवाज़" + }, + "customHoursPlaceholder": "पहुँच समाप्त होने में लगने वाले घंटे", + "shareButton": "शेयर करना", + "shareWithCount": "शेयर ({{count}})", + "currentAccess": "वर्तमान पहुंच", + "noAccessEntries": "इस होस्ट को अभी तक साझा नहीं किया गया है", + "grantedBy": "अनुमती देना", + "expires": "समय-सीमा समाप्त", + "expired": "खत्म हो चुका", + "never": "कभी नहीं", + "revoke": "रद्द करना", + "accessUpdated": "अपडेट एक्सेस करें", + "accessUpdateFailed": "एक्सेस अपडेट करने में विफल", + "sharedBadge": "साझा", + "sharedBadgeTooltip": "{{owner}} द्वारा साझा किया गया ({{level}} पहुंच)", + "viewOnlyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ साझा किया गया है और इसे देखने की अनुमति है। कॉन्फ़िगरेशन केवल पढ़ने योग्य है।", + "sharedEditBanner": "यह होस्ट {{owner}} द्वारा संपादन अनुमति के साथ आपके साथ साझा किया गया है। परिवर्तन वास्तविक होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भों को केवल स्वामी द्वारा ही बदला जा सकता है।", + "ownerOnlyControl": "इसे केवल होस्ट का मालिक ही बदल सकता है।" }, "guac": { "connection": "संबंध", @@ -1302,28 +1367,10 @@ "backspaceKey": "बैकस्पेस कुंजी", "saveHostFirst": "पहले होस्ट को सेव करें।", "sharingOptionsAfterSave": "होस्ट को सेव करने के बाद शेयरिंग के विकल्प उपलब्ध हो जाते हैं।", - "sharingLoadError": "शेयरिंग डेटा लोड करने में विफल। कृपया अपना कनेक्शन जांचें और पुनः प्रयास करें।", - "shareHostSection": "होस्ट साझा करें", - "shareWithUser": "उपयोगकर्ता के साथ साझा करें", - "shareWithRole": "भूमिका के साथ साझा करें", - "selectUser": "उपयोगकर्ता का चयन करें", - "selectRole": "भूमिका का चयन करें", - "selectUserOption": "किसी उपयोगकर्ता का चयन करें...", - "selectRoleOption": "एक भूमिका चुनें...", "permissionLevel": "अनुमति स्तर", - "expiresInHours": "समाप्ति तिथि (घंटे)", - "noExpiryPlaceholder": "समाप्ति तिथि से पहले खाली छोड़ दें", - "shareBtn": "शेयर करना", - "currentAccess": "वर्तमान पहुंच", "typeHeader": "प्रकार", "targetHeader": "लक्ष्य", "permissionHeader": "अनुमति", - "grantedByHeader": "अनुमती देना", - "expiresHeader": "समय-सीमा समाप्त", - "noAccessEntries": "अभी तक प्रवेश के लिए कोई प्रविष्टियाँ नहीं हैं।", - "expiredLabel": "खत्म हो चुका", - "neverLabel": "कभी नहीं", - "revokeBtn": "रद्द करना", "cancelBtn": "रद्द करना", "savingBtn": "सहेजा जा रहा है...", "updateHostBtn": "होस्ट को अपडेट करें", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "पासवर्ड से लॉगिन करना अक्षम है। कृपया पासकी या किसी बाहरी प्रमाणीकरण प्रदाता का उपयोग करें।", "signInWithPasskey": "पासकी से लॉग इन करें", "passkeyLoginFailed": "पासकी लॉगिन विफल", - "attemptsRemaining": "{{count}} attempts remaining" + "attemptsRemaining": "{{count}} attempts remaining", + "confirmResetDataWipe": "इस खाते ने एन्क्रिप्शन अपग्रेड के बाद से लॉग इन नहीं किया है, इसलिए पुराने पासवर्ड के बिना इसका संग्रहित डेटा पुनर्प्राप्त नहीं किया जा सकता है। रीसेट करने से इसके होस्ट, क्रेडेंशियल और स्निपेट स्थायी रूप से हटा दिए जाएंगे। जारी रखें?" }, "hostKey": { "verifyNewHost": "SSH होस्ट कुंजी सत्यापित करें", @@ -2323,6 +2371,13 @@ "serviceLinks": "सेवा लिंक", "homepagePreview": "होमपेज का पूर्वावलोकन" }, + "donation": { + "title": "क्या आप टर्मिक्स का आनंद ले रहे हैं?", + "body": "टर्मिक्स एक मुफ़्त और ओपन सोर्स सॉफ़्टवेयर है, जिसे दो लोगों की टीम ने अपने खाली समय में बनाया और रखरखाव किया है। अगर इसने आपके किसी ऐसे व्यावसायिक टूल की जगह ले ली है जिसके लिए आप भुगतान कर रहे होते, तो आपका दान होस्टिंग लागत को कवर करने और विकास को जारी रखने में मदद करता है। फिलहाल, दान केवल क्रिप्टोकरेंसी में ही स्वीकार किए जाते हैं।", + "milestones": "दान से SAML और Kubernetes जैसी सुविधाओं को विकसित करने के लिए आवश्यक शोध और सीखने में लगने वाले समय को भी वित्त पोषित करने में मदद मिलती है। प्रगति देखने के लिए दान पृष्ठ पर जाएँ।", + "cta": "दान करें", + "dismiss": "शायद बाद में" + }, "dashboardTab": { "stable": "स्थिर", "hostsOnline": "ऑनलाइन होस्ट", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "व्यवस्थापक की स्थिति अपडेट करने में विफल", "allSessionsRevoked": "सभी सत्र रद्द कर दिए गए", "revokeSessionsFailed": "सत्र रद्द करने में विफल", + "manageUserData": "उपयोगकर्ता डेटा प्रबंधित करें", + "backToUsers": "उपयोगकर्ताओं पर वापस जाएँ", + "manageTabAccount": "खाता", + "manageTabHosts": "मेजबान", + "manageTabCredentials": "साख", + "manageTabSnippets": "स्निपेट्स", + "manageTabSessions": "सत्रों", + "manageTabDanger": "खतरा", + "manageEditorBack": "{{username}}के डेटा पर वापस जाएँ", + "dataLockedBadge": "बंद", + "dataLockedNotice": "{{username}}का डेटा तब तक लॉक रहता है जब तक वे अगली बार लॉग इन नहीं करते। तब तक उनके होस्ट, क्रेडेंशियल और स्निपेट देखे या संपादित नहीं किए जा सकते।", + "resetPasswordTitle": "पासवर्ड रीसेट", + "resetPasswordOidcOnly": "यह उपयोगकर्ता किसी बाहरी प्रदाता के माध्यम से लॉग इन करता है और उसके पास कोई पासवर्ड नहीं है।", + "resetPasswordPlaceholder": "नया पासवर्ड", + "resetPasswordBtn": "रीसेट करें", + "resetPasswordWorking": "रीसेट हो रहा है...", + "resetPasswordSuccess": "पासवर्ड रीसेट", + "resetPasswordSuccessWiped": "पासवर्ड रीसेट कर दिया गया है। उपयोगकर्ता का एन्क्रिप्टेड डेटा मिटा दिया गया है।", + "resetPasswordFailed": "पासवर्ड रीसेट करने में विफल", + "resetPasswordConfirmWipe": "{{username}} ने एन्क्रिप्शन अपग्रेड के बाद से लॉग इन नहीं किया है, इसलिए उनका डेटा रिकवर नहीं किया जा सकता है। अभी रीसेट करने से उनके होस्ट, क्रेडेंशियल और स्निपेट डिलीट हो जाएंगे। जारी रखें?", + "totpSectionTitle": "दो-कारक प्रमाणीकरण", + "totpStatusEnabled": "इस उपयोगकर्ता के लिए TOTP सक्षम है।", + "totpStatusDisabled": "इस उपयोगकर्ता के लिए TOTP सक्षम नहीं है।", + "disableTotp": "अक्षम करना", + "disableTotpConfirm": "{{username}}के लिए दो-कारक प्रमाणीकरण अक्षम करें? वे केवल अपने पासवर्ड से लॉग इन कर सकेंगे।", + "totpDisabledSuccess": "टीओटीपी अक्षम", + "totpDisableFailed": "TOTP को निष्क्रिय करने में विफल", + "manageApiKeys": "एपीआई कुंजी", + "apiKeyNamePlaceholder": "कुंजी नाम", + "apiKeyCopyNotice": "इस कुंजी को अभी कॉपी कर लें, यह दोबारा नहीं दिखाई देगी।", + "noApiKeysForUser": "कोई एपीआई कुंजी नहीं", + "apiKeyDeleteFailed": "एपीआई कुंजी को हटाने में विफल रहा", + "exportUserData": "डेटा निर्यात", + "exportUserDataDesc": "इस उपयोगकर्ता के होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा को JSON फॉर्मेट में डाउनलोड करें। गुप्त जानकारी को डिक्रिप्ट कर दिया गया है।", + "exportUserDataSuccess": "उपयोगकर्ता डेटा निर्यात किया गया", + "exportUserDataFailed": "उपयोगकर्ता डेटा निर्यात करने में विफल", + "hostsCount": "{{count}} मेजबान", + "addHostForUser": "होस्ट जोड़ें", + "noHostsForUser": "इस उपयोगकर्ता के पास कोई होस्ट नहीं है", + "connectToHost": "जोड़ना", + "deleteHostConfirm": "{{username}} से संबंधित होस्ट \"{{name}}\" को हटाएँ?", + "hostDeletedSuccess": "होस्ट हटा दिया गया", + "hostDeleteFailed": "होस्ट को हटाने में विफल", + "credentialsCount": "{{count}} क्रेडेंशियल", + "addCredentialForUser": "क्रेडेंशियल जोड़ें", + "noCredentialsForUser": "इस उपयोगकर्ता के पास कोई क्रेडेंशियल नहीं है", + "deleteCredentialConfirm": "{{username}} से संबंधित क्रेडेंशियल \"{{name}}\" को हटाएँ?", + "credentialDeletedSuccess": "क्रेडेंशियल हटा दिया गया", + "credentialDeleteFailed": "क्रेडेंशियल हटाने में विफल", + "snippetsCount": "{{count}} स्निपेट्स", + "addSnippetForUser": "स्निपेट जोड़ें", + "noSnippetsForUser": "इस उपयोगकर्ता के पास कोई स्निपेट नहीं है", + "snippetRequiredFields": "स्निपेट का नाम और सामग्री आवश्यक हैं", + "snippetNamePlaceholder": "स्निपेट नाम", + "snippetContentPlaceholder": "कमांड सामग्री", + "snippetFolderPlaceholder": "फ़ोल्डर (वैकल्पिक)", + "snippetSaved": "अंश सहेजा गया", + "snippetSaveFailed": "स्निपेट सहेजने में विफल", + "deleteSnippetConfirm": "{{username}} से संबंधित अंश \"{{name}}\" को हटाएँ?", + "snippetDeletedSuccess": "अंश हटा दिया गया", + "snippetDeleteFailed": "स्निपेट को हटाने में विफल", + "noSessionsForUser": "कोई सक्रिय सत्र नहीं", + "deleteUserDangerDesc": "{{username}} और उनके सभी डेटा (होस्ट, क्रेडेंशियल, स्निपेट, इतिहास) को स्थायी रूप से हटा दें। इसे पूर्ववत नहीं किया जा सकता है।", + "deleteUserConfirm": "{{username}} और उनके सभी डेटा को स्थायी रूप से हटा दें?", + "deleteUserAdminBlocked": "इस उपयोगकर्ता को हटाने से पहले उसका व्यवस्थापक दर्जा हटा दें।", "createRoleRequired": "नाम और डिस्प्ले नाम आवश्यक हैं", "createRoleSuccess": "भूमिका \"{{name}}\" बनाई गई", "createRoleFailed": "भूमिका बनाने में विफल", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "कमान का इतिहास", "hostDefaultsCommandHistoryDesc": "नए होस्ट पर कमांड इतिहास को डिफ़ॉल्ट रूप से ट्रैक करें", "hostDefaultsSaved": "होस्ट डिफ़ॉल्ट सहेजे गए", - "hostDefaultsSaveFailed": "होस्ट डिफ़ॉल्ट सहेजने में विफल" + "hostDefaultsSaveFailed": "होस्ट डिफ़ॉल्ट सहेजने में विफल", + "rolePermissions": { + "count": "{{count}} अनुमतियाँ", + "editAction": "संपादन अनुमतियाँ", + "loadError": "अनुमतियों की सूची लोड करने में विफल।", + "saved": "भूमिका अनुमतियाँ सहेजी गईं", + "saveError": "भूमिका अनुमतियों को सहेजने में विफल", + "save": "बचाना", + "saving": "सहेजा जा रहा है..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "असफल" }, "userProfile": { + "donateTitle": "टर्मिक्स को जीवित रखें", + "donateDescription": "टर्मिक्स मुफ़्त है, सेल्फ-होस्टेड है और इसे कुछ ही लोगों ने अपने खाली समय में बनाया है। अगर इससे आपका समय या पैसा बचा है, तो क्रिप्टोकरेंसी में दान करने से इसे चालू रखने में मदद मिलेगी।", + "donateMilestones": "दान से SAML, Kubernetes और एजेंट सपोर्ट बनाने के लिए आवश्यक शोध और सीखने में लगने वाले समय को वित्त पोषित करने में मदद मिलती है। प्रगति देखें और दान करें।", + "donateButton": "क्रिप्टोकरेंसी दान करें", "storageModeLocal": "ब्राउज़र", "storageModeCloud": "डेटाबेस", "storageModeDescription": "ब्राउज़र सेटिंग्स को केवल इसी ब्राउज़र में स्टोर करता है। डेटाबेस सर्वर से सिंक्रोनाइज़ होता है और किसी भी डिवाइस पर लोड हो जाता है।", @@ -3011,6 +3144,10 @@ "twoFaOn": "पर", "twoFaOff": "बंद", "versionLabel": "संस्करण", + "betaProgramTitle": "बीटा प्रोग्राम", + "betaProgramDescription": "साप्ताहिक रूप से जारी होने वाले :beta Docker टैग के साथ आगामी सुविधाओं को पहले ही आजमाएं। यह अस्थिर है और उत्पादन के लिए उपयुक्त नहीं है।", + "betaProgramFeedback": "कोई बग मिला? इसकी रिपोर्ट यहां करें।", + "betaProgramLearnMore": "और अधिक जानें", "deleteAccount": "खाता हटा दो", "deleteAccountDescription": "अपना खाता स्थायी रूप से हटाएँ", "changeServerDescription": "किसी दूसरे टर्मिक्स बैकएंड सर्वर पर स्विच करें", diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index 831d391e..29e358f1 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Vesszővel elválasztott névminták, amelyek lehetővé teszik a Docker számára a vendégek egyezését", "proxmoxPreferredRanges": "Előnyben részesített IP-tartományok", "proxmoxPreferredRangesDesc": "Vesszővel elválasztott előtagok prioritási sorrendben IP-cím kiválasztásához, ha egy vendégnek több interfésze van", + "proxmoxAutoSync": "Vendégek automatikus szinkronizálása", + "proxmoxAutoSyncDesc": "Rendszeresen fedezze fel ezt a Proxmox csomópontot, és hozzon létre vagy frissítsen importált vendéggazdagépeket, miközben a munkamenete fel van oldva.", + "proxmoxSyncInterval": "Szinkronizálási időköz (perc)", + "proxmoxSyncIntervalDesc": "Minimum 5 perc. Az ütemező kihagyja a zárolt felhasználói munkameneteket.", + "proxmoxMarkMissing": "Jelöld meg az eltűnt vendégeket", + "proxmoxMarkMissingDesc": "Hozzáad egy proxmox-missing címkét, amikor egy korábban importált vendég eltűnik, ahelyett, hogy törölné azt.", + "proxmoxLastSync": "Utolsó szinkronizálás", + "proxmoxLastSyncNever": "Még nincs szinkronizálva", + "proxmoxLastSyncNoResult": "Nincsenek találati részletek", + "proxmoxLastSyncSummary": "{{created}} létrehozva, {{updated}} frissítve, {{markedMissing}} hiányzik, {{skipped}} kihagyva", + "proxmoxLastSyncStatus": { + "success": "Siker", + "error": "Sikertelen", + "pending": "Függőben levő" + }, "proxmoxDiscoverAction": "Proxmox vendégek felfedezése és importálása", "proxmoxImportTitle": "Importálás a Proxmoxból", "proxmoxSelectHost": "Válasszon egy Proxmox hosztot…", @@ -1167,7 +1182,57 @@ "shareHost": "Megosztási házigazda", "shareHostTitle": "Megosztás: {{name}}", "sharing": { - "requiresCredential": "Ennek a gazdagépnek hitelesítő adatot kell használnia a megosztás engedélyezéséhez. Először szerkessze a gazdagépet, és rendeljen hozzá egy hitelesítő adatot." + "loadError": "Nem sikerült betölteni a megosztási adatokat. Próbáld újra.", + "shareWithSection": "Megosztás", + "usersTab": "Felhasználók", + "rolesTab": "Szerepkörök", + "searchPlaceholder": "Felhasználók vagy szerepkörök keresése...", + "noMatches": "Nincs találat", + "permissionLevelLabel": "Jogosultsági szint", + "levels": { + "connect": { + "label": "Csatlakozás", + "description": "Csak nyitott munkamenetek: terminál, távoli asztal, fájlkezelő, alagutak és Docker. Nincs hozzáférés a gazdagép konfigurációjához." + }, + "view": { + "label": "Kilátás", + "description": "Csatlakozás, valamint a gazdagép konfigurációjának megtekintése. A titkos kódok soha nem jelennek meg." + }, + "edit": { + "label": "Szerkesztés", + "description": "A gazdagép megtekintése és módosítása. A titkos kódok lecserélhetők, de soha nem olvashatók; a hitelesítő adatok hozzárendelése csak a tulajdonos számára marad." + }, + "manage": { + "label": "Kezelés", + "description": "Szerkeszd, és oszd meg a gazdagépet másokkal, módosítsd a jogosultsági szinteket és vond vissza a hozzáférést." + } + }, + "expiryLabel": "Hozzáférés lejárata", + "expiry": { + "never": "Soha", + "oneHour": "1 óra", + "oneDay": "24 óra", + "sevenDays": "7 nap", + "thirtyDays": "30 nap", + "custom": "Szokás" + }, + "customHoursPlaceholder": "Órák a hozzáférés lejártáig", + "shareButton": "Részesedés", + "shareWithCount": "Megosztás ({{count}})", + "currentAccess": "Jelenlegi hozzáférés", + "noAccessEntries": "Ez a hoszt még nincs megosztva", + "grantedBy": "Megadta", + "expires": "Lejár", + "expired": "Lejárt", + "never": "Soha", + "revoke": "Visszavonás", + "accessUpdated": "Hozzáférés frissítve", + "accessUpdateFailed": "Nem sikerült frissíteni a hozzáférést", + "sharedBadge": "Megosztott", + "sharedBadgeTooltip": "Megosztotta {{owner}} ({{level}} hozzáférés)", + "viewOnlyBanner": "Ezt a hosztot {{owner}} osztotta meg veled megtekintési hozzáféréssel. A konfiguráció csak olvasható.", + "sharedEditBanner": "Ezt a hosztot {{owner}} osztotta meg veled szerkesztési hozzáféréssel. A változtatások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.", + "ownerOnlyControl": "Csak a tárhely tulajdonosa módosíthatja ezt" }, "guac": { "connection": "Kapcsolat", @@ -1302,28 +1367,10 @@ "backspaceKey": "Visszatörlés billentyű", "saveHostFirst": "Először mentsd el a gazdagépet.", "sharingOptionsAfterSave": "A megosztási beállítások a gazdagép mentése után érhetők el.", - "sharingLoadError": "Nem sikerült betölteni a megosztási adatokat. Ellenőrizd a kapcsolatot, és próbáld újra.", - "shareHostSection": "Megosztási házigazda", - "shareWithUser": "Megosztás a felhasználóval", - "shareWithRole": "Megosztás szerepkörrel", - "selectUser": "Felhasználó kiválasztása", - "selectRole": "Szerepkör kiválasztása", - "selectUserOption": "Válasszon ki egy felhasználót...", - "selectRoleOption": "Válasszon egy szerepet...", "permissionLevel": "Engedélyszint", - "expiresInHours": "Lejár (órán belül)", - "noExpiryPlaceholder": "Hagyja üresen, ha nincs lejárati idő", - "shareBtn": "Részesedés", - "currentAccess": "Jelenlegi hozzáférés", "typeHeader": "Típus", "targetHeader": "Cél", "permissionHeader": "Engedély", - "grantedByHeader": "Megadta", - "expiresHeader": "Lejár", - "noAccessEntries": "Még nincsenek hozzáférési bejegyzések.", - "expiredLabel": "Lejárt", - "neverLabel": "Soha", - "revokeBtn": "Visszavonás", "cancelBtn": "Mégsem", "savingBtn": "Megtakarítás...", "updateHostBtn": "Frissítse a gazdagépet", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "A jelszóval történő bejelentkezés le van tiltva. Használjon jelszót vagy külső hitelesítési szolgáltatót.", "signInWithPasskey": "Bejelentkezés jelszóval", "passkeyLoginFailed": "Jelszóval történő bejelentkezés sikertelen", - "attemptsRemaining": "{{count}} próbálkozás van hátra" + "attemptsRemaining": "{{count}} próbálkozás van hátra", + "confirmResetDataWipe": "Ez a fiók a titkosítás frissítése óta nem jelentkezett be, így a tárolt adatok nem állíthatók vissza a régi jelszó nélkül. A visszaállítás véglegesen törli a gazdagépeket, a hitelesítő adatokat és a kódrészleteket. Folytatja?" }, "hostKey": { "verifyNewHost": "SSH host kulcs ellenőrzése", @@ -2323,6 +2371,13 @@ "serviceLinks": "Szolgáltatási linkek", "homepagePreview": "Kezdőlap előnézete" }, + "donation": { + "title": "Élvezed a Termixet?", + "body": "A Termix ingyenes és nyílt forráskódú, egy kétfős csapat fejleszti és tartja karban a szabadidőnkben. Ha egy olyan kereskedelmi eszközt vált ki, amelyért egyébként fizetne, egy adomány segít fedezni a tárhelyköltségeket és fenntartja a fejlesztést. Jelenleg az adományok kizárólag kriptovalutákban fogadhatók.", + "milestones": "Az adományok segítenek finanszírozni az olyan funkciók kutatását és megismerését is, mint a SAML és a Kubernetes támogatása. A folyamat előrehaladását az adományozási oldalon tekintheti meg.", + "cta": "Adományoz", + "dismiss": "Talán később" + }, "dashboardTab": { "stable": "STABIL", "hostsOnline": "Online házigazdák", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Nem sikerült frissíteni az adminisztrátori állapotot", "allSessionsRevoked": "Minden munkamenet visszavonva", "revokeSessionsFailed": "Nem sikerült visszavonni a munkameneteket", + "manageUserData": "Felhasználói adatok kezelése", + "backToUsers": "Vissza a felhasználókhoz", + "manageTabAccount": "Fiók", + "manageTabHosts": "Házigazdák", + "manageTabCredentials": "Hitelesítő adatok", + "manageTabSnippets": "Kódrészletek", + "manageTabSessions": "Munkamenetek", + "manageTabDanger": "Veszély", + "manageEditorBack": "Vissza {{username}}adataihoz", + "dataLockedBadge": "BEZÁRT", + "dataLockedNotice": "{{username}}adatai zárolva maradnak a következő bejelentkezésig. A hosztjaik, hitelesítő adataik és kódrészleteik addig nem tekinthetők meg és nem szerkeszthetők.", + "resetPasswordTitle": "Jelszó visszaállítása", + "resetPasswordOidcOnly": "Ez a felhasználó egy külső szolgáltatón keresztül jelentkezik be, és nincs jelszava.", + "resetPasswordPlaceholder": "Új jelszó", + "resetPasswordBtn": "Visszaállítás", + "resetPasswordWorking": "Visszaállítás...", + "resetPasswordSuccess": "Jelszó visszaállítása", + "resetPasswordSuccessWiped": "Jelszó visszaállítása. A felhasználó titkosított adatait törölték.", + "resetPasswordFailed": "Nem sikerült visszaállítani a jelszót", + "resetPasswordConfirmWipe": "{{username}} nem jelentkezett be a titkosítás frissítése óta, így az adatai nem állíthatók vissza. A visszaállítás most törli a gazdagépeit, hitelesítő adatait és kódrészleteit. Folytatja?", + "totpSectionTitle": "Kétfaktoros hitelesítés", + "totpStatusEnabled": "A TOTP engedélyezve van ehhez a felhasználóhoz.", + "totpStatusDisabled": "A TOTP nincs engedélyezve ehhez a felhasználóhoz.", + "disableTotp": "Letiltás", + "disableTotpConfirm": "Letiltja a kétfaktoros hitelesítést a {{username}}felhasználó számára? Csak a jelszavával tudnak majd bejelentkezni.", + "totpDisabledSuccess": "TOTP letiltva", + "totpDisableFailed": "Nem sikerült letiltani a TOTP-t", + "manageApiKeys": "API-kulcsok", + "apiKeyNamePlaceholder": "Kulcs neve", + "apiKeyCopyNotice": "Másold le ezt a kulcsot most, nem fog újra megjelenni.", + "noApiKeysForUser": "Nincsenek API-kulcsok", + "apiKeyDeleteFailed": "Nem sikerült törölni az API-kulcsot", + "exportUserData": "Adatexportálás", + "exportUserDataDesc": "Töltse le a felhasználó hosztjait, hitelesítő adatait és fájlkezelői adatait JSON formátumban. A titkos kódok visszafejtése megtörtént.", + "exportUserDataSuccess": "Exportált felhasználói adatok", + "exportUserDataFailed": "Nem sikerült exportálni a felhasználói adatokat", + "hostsCount": "{{count}} gazdagépek", + "addHostForUser": "Gazdagép hozzáadása", + "noHostsForUser": "Ennek a felhasználónak nincsenek hosztjai", + "connectToHost": "Csatlakozás", + "deleteHostConfirm": "Törölni kell a(z) {{username}}-hez tartozó \"{{name}}\" hosztot?", + "hostDeletedSuccess": "Gazdagép törölve", + "hostDeleteFailed": "Nem sikerült törölni a gazdagépet", + "credentialsCount": "{{count}} hitelesítő adatok", + "addCredentialForUser": "Hitelesítő adat hozzáadása", + "noCredentialsForUser": "Ennek a felhasználónak nincsenek hitelesítő adatai", + "deleteCredentialConfirm": "Törölje a(z) {{username}} felhasználóhoz tartozó \"{{name}}\" hitelesítő adatot?", + "credentialDeletedSuccess": "Hitelesítő adat törölve", + "credentialDeleteFailed": "Nem sikerült törölni a hitelesítő adatokat", + "snippetsCount": "{{count}} kódrészletek", + "addSnippetForUser": "Kódrészlet hozzáadása", + "noSnippetsForUser": "Ennek a felhasználónak nincsenek kódrészletei", + "snippetRequiredFields": "A részlet nevének és tartalmának megadása kötelező", + "snippetNamePlaceholder": "Kódrészlet neve", + "snippetContentPlaceholder": "Parancs tartalma", + "snippetFolderPlaceholder": "Mappa (opcionális)", + "snippetSaved": "Kódrészlet mentve", + "snippetSaveFailed": "Nem sikerült menteni a részletet", + "deleteSnippetConfirm": "Törölni a(z) {{username}} felhasználóhoz tartozó \"{{name}}\" kódrészletet?", + "snippetDeletedSuccess": "Kódrészlet törölve", + "snippetDeleteFailed": "Nem sikerült törölni a részletet", + "noSessionsForUser": "Nincsenek aktív munkamenetek", + "deleteUserDangerDesc": "A(z) {{username}} és az összes hozzá tartozó adat (hosztok, hitelesítő adatok, kódrészletek, előzmények) végleges törlése. Ez a művelet nem vonható vissza.", + "deleteUserConfirm": "Véglegesen töröljem a(z) {{username}} felhasználót és az összes adatát?", + "deleteUserAdminBlocked": "A felhasználó törlése előtt távolítsa el az adminisztrátori státuszt.", "createRoleRequired": "Név és megjelenített név megadása kötelező", "createRoleSuccess": "\"{{name}}\" szerepkör létrehozva", "createRoleFailed": "Nem sikerült létrehozni a szerepkört", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Parancsnoki előzmények", "hostDefaultsCommandHistoryDesc": "Parancselőzmények követése új hosztokon alapértelmezés szerint", "hostDefaultsSaved": "Gazdagép alapértelmezett beállításai mentve", - "hostDefaultsSaveFailed": "Nem sikerült menteni a gazdagép alapértelmezett beállításait" + "hostDefaultsSaveFailed": "Nem sikerült menteni a gazdagép alapértelmezett beállításait", + "rolePermissions": { + "count": "{{count}} jogosultságok", + "editAction": "Szerkesztési engedélyek", + "loadError": "Nem sikerült betölteni az engedélykatalógust", + "saved": "Szerepkör-engedélyek mentve", + "saveError": "Nem sikerült menteni a szerepkör-engedélyeket", + "save": "Megtakarítás", + "saving": "Megtakarítás..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Sikertelen" }, "userProfile": { + "donateTitle": "Tartsd életben a Termixet", + "donateDescription": "A Termix ingyenes, saját tárhelyen üzemel, és csak néhány ember fejlesztette a szabadidejében. Ha időt vagy pénzt takarított meg vele, egy kriptovaluta-adomány segíthet a működésben.", + "donateMilestones": "Az adományok segítenek finanszírozni a kutatást és a SAML, a Kubernetes és az Agent-támogatás kiépítéséhez szükséges dolgok megismerését. Tekintse meg a haladást, és adományozzon.", + "donateButton": "Adományozz kriptovalutát", "storageModeLocal": "Böngésző", "storageModeCloud": "Adatbázis", "storageModeDescription": "A böngésző csak ebben a böngészőben tárolja a beállításokat. Az adatbázis szinkronizálódik a szerverrel, és bármilyen eszközön betöltődik.", @@ -3011,6 +3144,10 @@ "twoFaOn": "On", "twoFaOff": "Le", "versionLabel": "Változat", + "betaProgramTitle": "Béta program", + "betaProgramDescription": "Próbáld ki a legújabb funkciókat korán a heti :beta Docker címkével. Instabil, nem éles környezetben használható.", + "betaProgramFeedback": "Hibát találtál? Jelentsd itt.", + "betaProgramLearnMore": "Tudj meg többet", "deleteAccount": "Fiók törlése", "deleteAccountDescription": "Fiók végleges törlése", "changeServerDescription": "Váltás másik Termix háttérkiszolgálóra", diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index 6408deda..f48afa19 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Pola nama yang dipisahkan koma yang memungkinkan Docker untuk mencocokkan mesin virtual.", "proxmoxPreferredRanges": "Rentang IP yang disukai", "proxmoxPreferredRangesDesc": "Awalan yang dipisahkan koma dalam urutan prioritas untuk pemilihan IP ketika tamu memiliki beberapa antarmuka.", + "proxmoxAutoSync": "Sinkronisasi otomatis tamu", + "proxmoxAutoSyncDesc": "Secara berkala, temukan node Proxmox ini dan buat atau perbarui host tamu yang diimpor saat sesi Anda tidak terkunci.", + "proxmoxSyncInterval": "Interval sinkronisasi (menit)", + "proxmoxSyncIntervalDesc": "Minimal 5 menit. Penjadwal akan melewati sesi pengguna yang terkunci.", + "proxmoxMarkMissing": "Tandai tamu yang hilang", + "proxmoxMarkMissingDesc": "Tambahkan tag proxmox-missing ketika tamu yang sebelumnya diimpor menghilang, alih-alih menghapusnya.", + "proxmoxLastSync": "Sinkronisasi terakhir", + "proxmoxLastSyncNever": "Belum disinkronkan", + "proxmoxLastSyncNoResult": "Tidak ada detail hasil.", + "proxmoxLastSyncSummary": "{{created}} dibuat, {{updated}} diperbarui, {{markedMissing}} hilang, {{skipped}} dilewati", + "proxmoxLastSyncStatus": { + "success": "Kesuksesan", + "error": "Gagal", + "pending": "Tertunda" + }, "proxmoxDiscoverAction": "Temukan & impor tamu Proxmox", "proxmoxImportTitle": "Impor dari Proxmox", "proxmoxSelectHost": "Pilih host Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Bagikan Host", "shareHostTitle": "Bagikan: {{name}}", "sharing": { - "requiresCredential": "Host ini harus menggunakan kredensial agar dapat berbagi. Edit host dan tetapkan kredensial terlebih dahulu." + "loadError": "Gagal memuat data berbagi. Silakan coba lagi.", + "shareWithSection": "Bagikan dengan", + "usersTab": "Pengguna", + "rolesTab": "Peran", + "searchPlaceholder": "Cari pengguna atau peran...", + "noMatches": "Tidak ditemukan kecocokan.", + "permissionLevelLabel": "Tingkat izin", + "levels": { + "connect": { + "label": "Menghubungkan", + "description": "Hanya sesi yang diizinkan untuk dibuka: terminal, desktop jarak jauh, pengelola file, terowongan, dan Docker. Tidak ada akses ke konfigurasi host." + }, + "view": { + "label": "Melihat", + "description": "Hubungkan, dan lihat konfigurasi host. Rahasia tidak akan pernah ditampilkan." + }, + "edit": { + "label": "Edit", + "description": "Lihat, dan modifikasi host. Rahasia dapat diganti tetapi tidak pernah dibaca; penetapan kredensial tetap hanya untuk pemilik." + }, + "manage": { + "label": "Mengelola", + "description": "Edit, bagikan host dengan orang lain, ubah tingkat izin, dan cabut akses." + } + }, + "expiryLabel": "Kedaluwarsa akses", + "expiry": { + "never": "Tidak pernah", + "oneHour": "1 jam", + "oneDay": "24 jam", + "sevenDays": "7 hari", + "thirtyDays": "30 hari", + "custom": "Kebiasaan" + }, + "customHoursPlaceholder": "Waktu hingga akses berakhir", + "shareButton": "Membagikan", + "shareWithCount": "Bagikan ({{count}})", + "currentAccess": "Akses saat ini", + "noAccessEntries": "Host ini belum dibagikan", + "grantedBy": "Diberikan oleh", + "expires": "Berakhir", + "expired": "Kedaluwarsa", + "never": "Tidak pernah", + "revoke": "Menarik kembali", + "accessUpdated": "Akses diperbarui", + "accessUpdateFailed": "Gagal memperbarui akses", + "sharedBadge": "Dibagikan", + "sharedBadgeTooltip": "Dibagikan oleh {{owner}} ( akses{{level}})", + "viewOnlyBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses lihat. Konfigurasinya hanya baca.", + "sharedEditBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses edit. Perubahan berlaku untuk host sebenarnya; referensi otentikasi hanya dapat diubah oleh pemilik.", + "ownerOnlyControl": "Hanya pemilik host yang dapat mengubah ini." }, "guac": { "connection": "Koneksi", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tombol Backspace", "saveHostFirst": "Selamatkan tuan rumah terlebih dahulu.", "sharingOptionsAfterSave": "Opsi berbagi tersedia setelah host disimpan.", - "sharingLoadError": "Gagal memuat data berbagi. Periksa koneksi Anda dan coba lagi.", - "shareHostSection": "Bagikan Host", - "shareWithUser": "Bagikan dengan Pengguna", - "shareWithRole": "Bagikan dengan Peran", - "selectUser": "Pilih Pengguna", - "selectRole": "Pilih Peran", - "selectUserOption": "Pilih pengguna...", - "selectRoleOption": "Pilih peran...", "permissionLevel": "Tingkat Izin", - "expiresInHours": "Berakhir dalam (jam)", - "noExpiryPlaceholder": "Biarkan kosong agar tidak ada tanggal kedaluwarsa.", - "shareBtn": "Membagikan", - "currentAccess": "Akses Saat Ini", "typeHeader": "Jenis", "targetHeader": "Target", "permissionHeader": "Izin", - "grantedByHeader": "Diberikan Oleh", - "expiresHeader": "Berakhir", - "noAccessEntries": "Belum ada entri akses.", - "expiredLabel": "Kedaluwarsa", - "neverLabel": "Tidak pernah", - "revokeBtn": "Menarik kembali", "cancelBtn": "Membatalkan", "savingBtn": "Penghematan...", "updateHostBtn": "Perbarui Host", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Login dengan kata sandi dinonaktifkan. Gunakan kata sandi atau penyedia autentikasi eksternal.", "signInWithPasskey": "Masuk dengan kata sandi", "passkeyLoginFailed": "Login dengan kata sandi gagal.", - "attemptsRemaining": "{{count}} percobaan tersisa" + "attemptsRemaining": "{{count}} percobaan tersisa", + "confirmResetDataWipe": "Akun ini belum masuk sejak peningkatan enkripsi, jadi data yang tersimpan tidak dapat dipulihkan tanpa kata sandi lama. Mengatur ulang akan menghapus host, kredensial, dan cuplikan secara permanen. Lanjutkan?" }, "hostKey": { "verifyNewHost": "Verifikasi Kunci Host SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Tautan Layanan", "homepagePreview": "Pratinjau Halaman Utama" }, + "donation": { + "title": "Apakah Anda menikmati Termix?", + "body": "Termix adalah perangkat lunak gratis dan sumber terbuka, yang dibangun dan dipelihara oleh tim beranggotakan dua orang di waktu luang kami. Jika Termix menggantikan alat komersial yang biasanya Anda bayar, donasi akan membantu menutupi biaya hosting dan menjaga pengembangan tetap berjalan. Saat ini, donasi hanya dalam bentuk kripto.", + "milestones": "Donasi juga membantu mendanai waktu untuk meneliti dan mempelajari apa yang dibutuhkan untuk membangun fitur-fitur seperti dukungan SAML dan Kubernetes. Lihat perkembangannya di halaman donasi.", + "cta": "Menyumbangkan", + "dismiss": "mungkin nanti" + }, "dashboardTab": { "stable": "STABIL", "hostsOnline": "Host Online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Gagal memperbarui status admin", "allSessionsRevoked": "Semua sesi dibatalkan", "revokeSessionsFailed": "Gagal mencabut sesi", + "manageUserData": "Kelola data pengguna", + "backToUsers": "Kembali ke halaman pengguna", + "manageTabAccount": "Akun", + "manageTabHosts": "Tuan rumah", + "manageTabCredentials": "Kredensial", + "manageTabSnippets": "Cuplikan", + "manageTabSessions": "Sesi", + "manageTabDanger": "Bahaya", + "manageEditorBack": "Kembali ke data {{username}}", + "dataLockedBadge": "TERKUNCI", + "dataLockedNotice": "Data {{username}}tetap terkunci hingga mereka masuk kembali. Host, kredensial, dan cuplikan mereka tidak dapat dilihat atau diedit hingga saat itu.", + "resetPasswordTitle": "Atur Ulang Kata Sandi", + "resetPasswordOidcOnly": "Pengguna ini masuk melalui penyedia eksternal dan tidak memiliki kata sandi.", + "resetPasswordPlaceholder": "Kata sandi baru", + "resetPasswordBtn": "Mengatur ulang", + "resetPasswordWorking": "Mengatur ulang...", + "resetPasswordSuccess": "Reset kata sandi", + "resetPasswordSuccessWiped": "Kata sandi telah direset. Data terenkripsi pengguna telah dihapus.", + "resetPasswordFailed": "Gagal mengatur ulang kata sandi", + "resetPasswordConfirmWipe": "{{username}} belum masuk sejak peningkatan enkripsi, jadi data mereka tidak dapat dipulihkan. Mengatur ulang sekarang akan menghapus host, kredensial, dan cuplikan mereka. Lanjutkan?", + "totpSectionTitle": "Autentikasi Dua Faktor", + "totpStatusEnabled": "TOTP diaktifkan untuk pengguna ini.", + "totpStatusDisabled": "TOTP tidak diaktifkan untuk pengguna ini.", + "disableTotp": "Cacat", + "disableTotpConfirm": "Nonaktifkan otentikasi dua faktor untuk {{username}}? Mereka akan dapat masuk hanya dengan kata sandi mereka.", + "totpDisabledSuccess": "TOTP dinonaktifkan", + "totpDisableFailed": "Gagal menonaktifkan TOTP", + "manageApiKeys": "Kunci API", + "apiKeyNamePlaceholder": "Nama kunci", + "apiKeyCopyNotice": "Salin kunci ini sekarang, kunci ini tidak akan ditampilkan lagi.", + "noApiKeysForUser": "Tidak ada kunci API", + "apiKeyDeleteFailed": "Gagal menghapus kunci API", + "exportUserData": "Ekspor Data", + "exportUserDataDesc": "Unduh data host, kredensial, dan pengelola file pengguna ini sebagai JSON. Rahasia akan didekripsi.", + "exportUserDataSuccess": "Data pengguna diekspor", + "exportUserDataFailed": "Gagal mengekspor data pengguna", + "hostsCount": "{{count}} tuan rumah", + "addHostForUser": "Tambahkan Host", + "noHostsForUser": "Pengguna ini tidak memiliki host.", + "connectToHost": "Menghubungkan", + "deleteHostConfirm": "Hapus host \"{{name}}\" milik {{username}}?", + "hostDeletedSuccess": "Host dihapus", + "hostDeleteFailed": "Gagal menghapus host", + "credentialsCount": "{{count}} kredensial", + "addCredentialForUser": "Tambahkan Kredensial", + "noCredentialsForUser": "Pengguna ini tidak memiliki kredensial.", + "deleteCredentialConfirm": "Hapus kredensial \"{{name}}\" milik {{username}}?", + "credentialDeletedSuccess": "Kredensial dihapus", + "credentialDeleteFailed": "Gagal menghapus kredensial", + "snippetsCount": "{{count}} cuplikan", + "addSnippetForUser": "Tambahkan Cuplikan", + "noSnippetsForUser": "Pengguna ini tidak memiliki cuplikan.", + "snippetRequiredFields": "Nama cuplikan dan konten wajib diisi.", + "snippetNamePlaceholder": "Nama cuplikan", + "snippetContentPlaceholder": "Isi perintah", + "snippetFolderPlaceholder": "Folder (opsional)", + "snippetSaved": "Cuplikan tersimpan", + "snippetSaveFailed": "Gagal menyimpan cuplikan", + "deleteSnippetConfirm": "Hapus cuplikan \"{{name}}\" milik {{username}}?", + "snippetDeletedSuccess": "Cuplikan dihapus", + "snippetDeleteFailed": "Gagal menghapus cuplikan", + "noSessionsForUser": "Tidak ada sesi aktif", + "deleteUserDangerDesc": "Hapus secara permanen {{username}} dan semua datanya (host, kredensial, cuplikan, riwayat). Tindakan ini tidak dapat dibatalkan.", + "deleteUserConfirm": "Hapus secara permanen {{username}} dan semua datanya?", + "deleteUserAdminBlocked": "Hapus status admin sebelum menghapus pengguna ini.", "createRoleRequired": "Nama dan nama tampilan wajib diisi.", "createRoleSuccess": "Peran \"{{name}}\" dibuat", "createRoleFailed": "Gagal membuat peran", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Riwayat Perintah", "hostDefaultsCommandHistoryDesc": "Lacak riwayat perintah pada host baru secara default.", "hostDefaultsSaved": "Pengaturan default host tersimpan.", - "hostDefaultsSaveFailed": "Gagal menyimpan pengaturan default host." + "hostDefaultsSaveFailed": "Gagal menyimpan pengaturan default host.", + "rolePermissions": { + "count": "{{count}} izin", + "editAction": "Edit izin", + "loadError": "Gagal memuat katalog izin.", + "saved": "Izin peran tersimpan", + "saveError": "Gagal menyimpan izin peran", + "save": "Menyimpan", + "saving": "Penghematan..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Gagal" }, "userProfile": { + "donateTitle": "Jaga agar Termix tetap hidup.", + "donateDescription": "Termix gratis, dihosting sendiri, dan dibangun hanya oleh beberapa orang di waktu luang mereka. Jika Termix telah menghemat waktu atau uang Anda, donasi kripto akan membantu kelangsungannya.", + "donateMilestones": "Donasi membantu mendanai waktu yang dibutuhkan untuk meneliti dan mempelajari apa yang diperlukan untuk membangun dukungan SAML, Kubernetes, dan Agent. Lihat perkembangannya dan berikan donasi.", + "donateButton": "Donasikan kripto", "storageModeLocal": "Browser", "storageModeCloud": "Basis data", "storageModeDescription": "Browser hanya menyimpan pengaturan di browser ini saja. Basis data disinkronkan ke server dan dimuat di perangkat apa pun.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Pada", "twoFaOff": "Mati", "versionLabel": "Versi", + "betaProgramTitle": "Program Beta", + "betaProgramDescription": "Coba fitur-fitur mendatang lebih awal dengan tag Docker :beta yang diperbarui setiap minggu. Tidak stabil, bukan untuk produksi.", + "betaProgramFeedback": "Menemukan bug? Laporkan di sini.", + "betaProgramLearnMore": "Pelajari Selengkapnya", "deleteAccount": "Hapus Akun", "deleteAccountDescription": "Hapus akun Anda secara permanen", "changeServerDescription": "Beralihlah ke server backend Termix yang berbeda.", diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 0ac63647..63c8d40a 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Modelli di nomi separati da virgole che consentono a Docker di abbinare gli ospiti", "proxmoxPreferredRanges": "Intervalli IP preferiti", "proxmoxPreferredRangesDesc": "Prefissi separati da virgole in ordine di priorità per la selezione dell'indirizzo IP quando una macchina virtuale dispone di più interfacce.", + "proxmoxAutoSync": "Sincronizzazione automatica degli ospiti", + "proxmoxAutoSyncDesc": "Rileva periodicamente questo nodo Proxmox e crea o aggiorna gli host guest importati mentre la tua sessione è sbloccata.", + "proxmoxSyncInterval": "Intervallo di sincronizzazione (minuti)", + "proxmoxSyncIntervalDesc": "Minimo 5 minuti. Lo scheduler salta le sessioni utente bloccate.", + "proxmoxMarkMissing": "Mark Ospiti mancanti", + "proxmoxMarkMissingDesc": "Aggiungi il tag proxmox-missing quando una macchina virtuale precedentemente importata scompare, invece di eliminarla.", + "proxmoxLastSync": "Ultima sincronizzazione", + "proxmoxLastSyncNever": "Non ancora sincronizzato", + "proxmoxLastSyncNoResult": "Nessun risultato disponibile", + "proxmoxLastSyncSummary": "{{created}} creato, {{updated}} aggiornato, {{markedMissing}} mancante, {{skipped}} saltato", + "proxmoxLastSyncStatus": { + "success": "Successo", + "error": "Fallito", + "pending": "In attesa di" + }, "proxmoxDiscoverAction": "Scopri e importa guest Proxmox", "proxmoxImportTitle": "Importa da Proxmox", "proxmoxSelectHost": "Seleziona un host Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Condividi l'host", "shareHostTitle": "Condividi: {{name}}", "sharing": { - "requiresCredential": "Questo host deve utilizzare una credenziale per abilitare la condivisione. Modifica l'host e assegna prima una credenziale." + "loadError": "Impossibile caricare i dati di condivisione. Riprova.", + "shareWithSection": "Condividi con", + "usersTab": "Utenti", + "rolesTab": "Ruoli", + "searchPlaceholder": "Cerca utenti o ruoli...", + "noMatches": "Nessuna corrispondenza trovata", + "permissionLevelLabel": "Livello di autorizzazione", + "levels": { + "connect": { + "label": "Collegare", + "description": "Sono consentite solo le seguenti sessioni: terminale, desktop remoto, file manager, tunnel e Docker. Nessun accesso alla configurazione dell'host." + }, + "view": { + "label": "Visualizzazione", + "description": "Connettiti e visualizza la configurazione dell'host. I segreti non vengono mai mostrati." + }, + "edit": { + "label": "Modificare", + "description": "Visualizza e modifica l'host. I segreti possono essere sostituiti ma non letti; le credenziali assegnate rimangono riservate al proprietario." + }, + "manage": { + "label": "Maneggio", + "description": "Modifica e condividi l'host con altri, cambia i livelli di autorizzazione e revoca l'accesso." + } + }, + "expiryLabel": "Scadenza dell'accesso", + "expiry": { + "never": "Mai", + "oneHour": "1 ora", + "oneDay": "24 ore", + "sevenDays": "7 giorni", + "thirtyDays": "30 giorni", + "custom": "Costume" + }, + "customHoursPlaceholder": "Ore rimanenti alla scadenza dell'accesso", + "shareButton": "Condividere", + "shareWithCount": "Condividi ({{count}})", + "currentAccess": "Accesso attuale", + "noAccessEntries": "Questo host non è stato ancora condiviso", + "grantedBy": "Concesso da", + "expires": "Scade", + "expired": "Scaduto", + "never": "Mai", + "revoke": "Revocare", + "accessUpdated": "Accesso aggiornato", + "accessUpdateFailed": "Impossibile aggiornare l'accesso", + "sharedBadge": "Condiviso", + "sharedBadgeTooltip": "Condiviso da {{owner}} ({{level}} accesso)", + "viewOnlyBanner": "Questo host è condiviso con te da {{owner}} con accesso in sola visualizzazione. La configurazione è di sola lettura.", + "sharedEditBanner": "Questo host è condiviso con te da {{owner}} con accesso in modifica. Le modifiche si applicano all'host reale; i riferimenti di autenticazione possono essere modificati solo dal proprietario.", + "ownerOnlyControl": "Solo il proprietario dell'host può modificare questo" }, "guac": { "connection": "Connessione", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tasto Backspace", "saveHostFirst": "Salva prima l'host.", "sharingOptionsAfterSave": "Le opzioni di condivisione saranno disponibili dopo aver salvato l'host.", - "sharingLoadError": "Impossibile caricare i dati di condivisione. Verifica la connessione e riprova.", - "shareHostSection": "Condividi l'host", - "shareWithUser": "Condividi con l'utente", - "shareWithRole": "Condividi con il ruolo", - "selectUser": "Seleziona utente", - "selectRole": "Seleziona il ruolo", - "selectUserOption": "Seleziona un utente...", - "selectRoleOption": "Seleziona un ruolo...", "permissionLevel": "Livello di autorizzazione", - "expiresInHours": "Scade tra (ore)", - "noExpiryPlaceholder": "Lasciare vuoto per non avere scadenza", - "shareBtn": "Condividere", - "currentAccess": "Accesso attuale", "typeHeader": "Tipo", "targetHeader": "Bersaglio", "permissionHeader": "Autorizzazione", - "grantedByHeader": "Concesso da", - "expiresHeader": "Scade", - "noAccessEntries": "Nessun accesso ancora registrato.", - "expiredLabel": "Scaduto", - "neverLabel": "Mai", - "revokeBtn": "Revocare", "cancelBtn": "Cancellare", "savingBtn": "Risparmio...", "updateHostBtn": "Aggiorna host", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "L'accesso tramite password è disabilitato. Utilizzare una chiave di accesso o un provider di autenticazione esterno.", "signInWithPasskey": "Accedi con la password", "passkeyLoginFailed": "Accesso con password non riuscito", - "attemptsRemaining": "{{count}} tentativi rimanenti" + "attemptsRemaining": "{{count}} tentativi rimanenti", + "confirmResetDataWipe": "Questo account non ha effettuato l'accesso dall'aggiornamento della crittografia, pertanto i dati memorizzati non possono essere recuperati senza la vecchia password. Il ripristino eliminerà definitivamente host, credenziali e snippet. Continuare?" }, "hostKey": { "verifyNewHost": "Verifica la chiave host SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Collegamenti ai servizi", "homepagePreview": "Anteprima della homepage" }, + "donation": { + "title": "Ti piace Termix?", + "body": "Termix è gratuito e open source, sviluppato e gestito da un team di due persone nel nostro tempo libero. Se ha sostituito uno strumento commerciale che altrimenti avresti pagato, una donazione contribuisce a coprire i costi di hosting e a sostenere lo sviluppo. Al momento, le donazioni sono accettate solo in criptovalute.", + "milestones": "Le donazioni contribuiscono anche a finanziare il tempo dedicato alla ricerca e all'apprendimento di ciò che è necessario per sviluppare funzionalità come il supporto per SAML e Kubernetes. È possibile seguire gli aggiornamenti sulla pagina delle donazioni.", + "cta": "Donare", + "dismiss": "Forse più tardi" + }, "dashboardTab": { "stable": "STABILE", "hostsOnline": "Host online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Impossibile aggiornare lo stato dell'amministratore.", "allSessionsRevoked": "Tutte le sessioni sono state revocate.", "revokeSessionsFailed": "Impossibile revocare le sessioni", + "manageUserData": "Gestire i dati utente", + "backToUsers": "Torna agli utenti", + "manageTabAccount": "Account", + "manageTabHosts": "Ospiti", + "manageTabCredentials": "Credenziali", + "manageTabSnippets": "Frammenti", + "manageTabSessions": "Sessioni", + "manageTabDanger": "Pericolo", + "manageEditorBack": "Torna ai dati di {{username}}", + "dataLockedBadge": "CHIUSO", + "dataLockedNotice": "I dati di {{username}}rimangono bloccati fino al loro prossimo accesso. I loro host, credenziali e snippet non possono essere visualizzati o modificati fino ad allora.", + "resetPasswordTitle": "Reimposta password", + "resetPasswordOidcOnly": "Questo utente effettua l'accesso tramite un provider esterno e non ha una password.", + "resetPasswordPlaceholder": "Nuova password", + "resetPasswordBtn": "Reset", + "resetPasswordWorking": "Ripristino in corso...", + "resetPasswordSuccess": "reimposta la password", + "resetPasswordSuccessWiped": "Reimpostazione della password. I dati crittografati dell'utente sono stati cancellati.", + "resetPasswordFailed": "Impossibile reimpostare la password", + "resetPasswordConfirmWipe": "{{username}} non ha effettuato l'accesso dall'aggiornamento della crittografia, pertanto i suoi dati non possono essere recuperati. Il ripristino ora eliminerà host, credenziali e snippet. Continuare?", + "totpSectionTitle": "Autenticazione a due fattori", + "totpStatusEnabled": "TOTP è abilitato per questo utente", + "totpStatusDisabled": "TOTP non è abilitato per questo utente", + "disableTotp": "Disabilita", + "disableTotpConfirm": "Disabilitare l'autenticazione a due fattori per {{username}}? Potranno accedere solo con la loro password.", + "totpDisabledSuccess": "TOTP disabilitato", + "totpDisableFailed": "Impossibile disattivare TOTP", + "manageApiKeys": "Chiavi API", + "apiKeyNamePlaceholder": "Nome chiave", + "apiKeyCopyNotice": "Copia subito questa chiave, non verrà più mostrata.", + "noApiKeysForUser": "Nessuna chiave API", + "apiKeyDeleteFailed": "Impossibile eliminare la chiave API", + "exportUserData": "Esportazione dei dati", + "exportUserDataDesc": "Scarica i dati relativi a host, credenziali e file manager di questo utente in formato JSON. I dati sensibili vengono decrittografati.", + "exportUserDataSuccess": "Dati utente esportati", + "exportUserDataFailed": "Impossibile esportare i dati utente", + "hostsCount": "{{count}} host", + "addHostForUser": "Aggiungi host", + "noHostsForUser": "Questo utente non ha host", + "connectToHost": "Collegare", + "deleteHostConfirm": "Eliminare l'host \"{{name}}\" appartenente a {{username}}?", + "hostDeletedSuccess": "Host eliminato", + "hostDeleteFailed": "Impossibile eliminare l'host", + "credentialsCount": "{{count}} credenziali", + "addCredentialForUser": "Aggiungi credenziali", + "noCredentialsForUser": "Questo utente non ha credenziali", + "deleteCredentialConfirm": "Eliminare le credenziali \"{{name}}\" appartenenti a {{username}}?", + "credentialDeletedSuccess": "Credenziali eliminate", + "credentialDeleteFailed": "Impossibile eliminare le credenziali", + "snippetsCount": "{{count}} frammenti", + "addSnippetForUser": "Aggiungi snippet", + "noSnippetsForUser": "Questo utente non ha snippet", + "snippetRequiredFields": "Il nome e il contenuto dello snippet sono obbligatori.", + "snippetNamePlaceholder": "Nome del frammento", + "snippetContentPlaceholder": "Contenuto del comando", + "snippetFolderPlaceholder": "Cartella (facoltativa)", + "snippetSaved": "Frammento salvato", + "snippetSaveFailed": "Impossibile salvare il frammento", + "deleteSnippetConfirm": "Eliminare il frammento \"{{name}}\" appartenente a {{username}}?", + "snippetDeletedSuccess": "Frammento eliminato", + "snippetDeleteFailed": "Impossibile eliminare il frammento", + "noSessionsForUser": "Nessuna sessione attiva", + "deleteUserDangerDesc": "Elimina definitivamente {{username}} e tutti i relativi dati (host, credenziali, snippet, cronologia). Questa operazione non può essere annullata.", + "deleteUserConfirm": "Eliminare definitivamente {{username}} e tutti i suoi dati?", + "deleteUserAdminBlocked": "Rimuovere i privilegi di amministratore prima di eliminare questo utente.", "createRoleRequired": "Nome e nome visualizzato sono obbligatori.", "createRoleSuccess": "Ruolo \"{{name}}\" creato", "createRoleFailed": "Impossibile creare il ruolo", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Storia del comando", "hostDefaultsCommandHistoryDesc": "Per impostazione predefinita, viene monitorata la cronologia dei comandi sui nuovi host.", "hostDefaultsSaved": "Hostdefaults salvato", - "hostDefaultsSaveFailed": "Impossibile salvare le impostazioni predefinite dell'host" + "hostDefaultsSaveFailed": "Impossibile salvare le impostazioni predefinite dell'host", + "rolePermissions": { + "count": "{{count}} permessi", + "editAction": "Modifica autorizzazioni", + "loadError": "Impossibile caricare il catalogo delle autorizzazioni.", + "saved": "Permessi di ruolo salvati", + "saveError": "Impossibile salvare le autorizzazioni del ruolo.", + "save": "Salva", + "saving": "Risparmio..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Fallito" }, "userProfile": { + "donateTitle": "Mantieni in vita Termix", + "donateDescription": "Termix è gratuito, autogestito e sviluppato da poche persone nel loro tempo libero. Se ti ha fatto risparmiare tempo o denaro, una donazione in criptovalute contribuisce a mantenerlo attivo.", + "donateMilestones": "Le donazioni contribuiscono a finanziare il tempo dedicato alla ricerca e all'apprendimento di ciò che è necessario per sviluppare il supporto per SAML, Kubernetes e Agent. Segui i progressi e dona.", + "donateButton": "Dona criptovalute", "storageModeLocal": "Sito web", "storageModeCloud": "Schermata", "storageModeDescription": "Il browser memorizza le impostazioni solo in questo browser. Il database si sincronizza con il server e viene caricato su qualsiasi dispositivo.", @@ -3011,6 +3144,10 @@ "twoFaOn": "SU", "twoFaOff": "Spento", "versionLabel": "Versione", + "betaProgramTitle": "Programma Beta", + "betaProgramDescription": "Prova in anteprima le nuove funzionalità con il tag Docker :beta, disponibile settimanalmente. Versione instabile, non adatta all'ambiente di produzione.", + "betaProgramFeedback": "Hai trovato un bug? Segnalalo qui.", + "betaProgramLearnMore": "Saperne di più", "deleteAccount": "Elimina account", "deleteAccountDescription": "Elimina definitivamente il tuo account", "changeServerDescription": "Passa a un server backend Termix diverso", diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index 9c38f955..0c13324e 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "ゲストのマッチングを可能にする、カンマ区切りの名前パターン", "proxmoxPreferredRanges": "推奨IPアドレス範囲", "proxmoxPreferredRangesDesc": "ゲストが複数のインターフェースを持つ場合のIP選択の優先順位を示すカンマ区切りのプレフィックス", + "proxmoxAutoSync": "ゲストの自動同期", + "proxmoxAutoSyncDesc": "セッションがロック解除されている間に、定期的にこの Proxmox ノードを検出し、インポート済みのゲストホストを作成または更新します。", + "proxmoxSyncInterval": "同期間隔(分)", + "proxmoxSyncIntervalDesc": "最低5分。スケジューラはロックされたユーザーセッションをスキップします。", + "proxmoxMarkMissing": "行方不明のゲストをマークする", + "proxmoxMarkMissingDesc": "以前インポートしたゲストが消えた場合、削除する代わりにproxmox-missingタグを追加する。", + "proxmoxLastSync": "最終同期", + "proxmoxLastSyncNever": "まだ同期されていません", + "proxmoxLastSyncNoResult": "結果の詳細はありません", + "proxmoxLastSyncSummary": "{{created}} 作成、 {{updated}} 更新、 {{markedMissing}} 欠落、 {{skipped}} スキップ", + "proxmoxLastSyncStatus": { + "success": "成功", + "error": "失敗した", + "pending": "保留中" + }, "proxmoxDiscoverAction": "Proxmoxゲストの検出とインポート", "proxmoxImportTitle": "Proxmoxからインポート", "proxmoxSelectHost": "Proxmoxホストを選択してください…", @@ -1167,7 +1182,57 @@ "shareHost": "共有ホスト", "shareHostTitle": "共有: {{name}}", "sharing": { - "requiresCredential": "このホストで共有を有効にするには、認証情報が必要です。まずホストを編集して認証情報を割り当ててください。" + "loadError": "共有データの読み込みに失敗しました。もう一度お試しください。", + "shareWithSection": "シェアする", + "usersTab": "ユーザー", + "rolesTab": "役割", + "searchPlaceholder": "ユーザーまたは役割を検索...", + "noMatches": "一致する項目が見つかりませんでした", + "permissionLevelLabel": "権限レベル", + "levels": { + "connect": { + "label": "接続する", + "description": "開いているセッションのみ:ターミナル、リモートデスクトップ、ファイルマネージャ、トンネル、およびDocker。ホスト構成へのアクセスはできません。" + }, + "view": { + "label": "ビュー", + "description": "接続してホストの設定を確認できます。機密情報は一切表示されません。" + }, + "edit": { + "label": "編集", + "description": "ホストの表示と変更が可能です。シークレットは置き換えることはできますが、読み取ることはできません。認証情報の割り当ては所有者のみが行えます。" + }, + "manage": { + "label": "管理", + "description": "編集、ホストの共有、権限レベルの変更、アクセス権の取り消しが可能です。" + } + }, + "expiryLabel": "アクセス期限切れ", + "expiry": { + "never": "一度もない", + "oneHour": "1時間", + "oneDay": "24時間", + "sevenDays": "7日間", + "thirtyDays": "30日間", + "custom": "カスタム" + }, + "customHoursPlaceholder": "アクセス期限まであと数時間", + "shareButton": "共有", + "shareWithCount": "シェア({{count}})", + "currentAccess": "現在のアクセス状況", + "noAccessEntries": "このホストはまだ共有されていません", + "grantedBy": "許可された", + "expires": "有効期限", + "expired": "期限切れ", + "never": "一度もない", + "revoke": "取り消す", + "accessUpdated": "アクセス情報が更新されました", + "accessUpdateFailed": "アクセス情報の更新に失敗しました", + "sharedBadge": "共有", + "sharedBadgeTooltip": "{{owner}} ({{level}} アクセス) によって共有されています", + "viewOnlyBanner": "このホストは、 {{owner}} によって閲覧権限で共有されています。設定は読み取り専用です。", + "sharedEditBanner": "このホストは、編集権限を持つ {{owner}} によって共有されています。変更は実際のホストに適用されます。認証情報は所有者のみが変更できます。", + "ownerOnlyControl": "ホストの所有者のみがこれを変更できます" }, "guac": { "connection": "繋がり", @@ -1302,28 +1367,10 @@ "backspaceKey": "バックスペースキー", "saveHostFirst": "まずホストをセーブしてください。", "sharingOptionsAfterSave": "ホストを保存すると、共有オプションが利用可能になります。", - "sharingLoadError": "共有データの読み込みに失敗しました。接続状況を確認して、もう一度お試しください。", - "shareHostSection": "共有ホスト", - "shareWithUser": "ユーザーと共有する", - "shareWithRole": "役割を共有", - "selectUser": "ユーザーを選択", - "selectRole": "役割を選択", - "selectUserOption": "ユーザーを選択してください...", - "selectRoleOption": "役割を選択してください...", "permissionLevel": "権限レベル", - "expiresInHours": "有効期限(時間)", - "noExpiryPlaceholder": "有効期限がない場合は空欄のままにしてください。", - "shareBtn": "共有", - "currentAccess": "現在のアクセス状況", "typeHeader": "タイプ", "targetHeader": "ターゲット", "permissionHeader": "許可", - "grantedByHeader": "許可者", - "expiresHeader": "有効期限", - "noAccessEntries": "アクセスエントリはまだありません。", - "expiredLabel": "期限切れ", - "neverLabel": "一度もない", - "revokeBtn": "取り消す", "cancelBtn": "キャンセル", "savingBtn": "保存中...", "updateHostBtn": "ホストの更新", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "パスワードによるログインは無効になっています。パスキーまたは外部認証プロバイダーをご利用ください。", "signInWithPasskey": "パスキーでサインイン", "passkeyLoginFailed": "パスキーログインに失敗しました", - "attemptsRemaining": "{{count}} 残り試行回数" + "attemptsRemaining": "{{count}} 残り試行回数", + "confirmResetDataWipe": "このアカウントは暗号化のアップグレード以降ログインしていないため、古いパスワードがないと保存されたデータを復元できません。リセットすると、ホスト、認証情報、スニペットが完全に削除されます。続行しますか?" }, "hostKey": { "verifyNewHost": "SSHホストキーを確認する", @@ -2323,6 +2371,13 @@ "serviceLinks": "サービスリンク", "homepagePreview": "ホームページのプレビュー" }, + "donation": { + "title": "Termixを楽しんでいますか?", + "body": "Termixは無料のオープンソースソフトウェアで、2人チームが空き時間を使って開発・保守しています。もしTermixが、本来有料で利用していた商用ツールの代替として使えるのであれば、ご寄付をいただけるとホスティング費用や開発継続に役立ちます。現時点では、寄付は仮想通貨のみ受け付けています。", + "milestones": "寄付金は、SAMLやKubernetesサポートといった機能の開発に必要な調査や学習に費やす時間にも役立てられます。寄付ページで進捗状況をご確認ください。", + "cta": "寄付する", + "dismiss": "後で" + }, "dashboardTab": { "stable": "安定した", "hostsOnline": "ホストがオンラインです", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "管理者ステータスの更新に失敗しました", "allSessionsRevoked": "すべてのセッションが取り消されました", "revokeSessionsFailed": "セッションの取り消しに失敗しました", + "manageUserData": "ユーザーデータの管理", + "backToUsers": "ユーザー一覧に戻る", + "manageTabAccount": "アカウント", + "manageTabHosts": "主催者", + "manageTabCredentials": "資格", + "manageTabSnippets": "断片", + "manageTabSessions": "セッション", + "manageTabDanger": "危険", + "manageEditorBack": "{{username}}のデータに戻る", + "dataLockedBadge": "ロック済み", + "dataLockedNotice": "{{username}}のデータは、次回ログインするまでロックされたままです。それまでは、ホスト、認証情報、スニペットを表示したり編集したりすることはできません。", + "resetPasswordTitle": "パスワードをリセット", + "resetPasswordOidcOnly": "このユーザーは外部プロバイダー経由でログインしており、パスワードは設定されていません。", + "resetPasswordPlaceholder": "新しいパスワード", + "resetPasswordBtn": "リセット", + "resetPasswordWorking": "リセット中...", + "resetPasswordSuccess": "パスワードリセット", + "resetPasswordSuccessWiped": "パスワードがリセットされました。ユーザーの暗号化されたデータは消去されました。", + "resetPasswordFailed": "パスワードのリセットに失敗しました", + "resetPasswordConfirmWipe": "{{username}} は暗号化アップグレード以降ログインしていないため、データは復元できません。今すぐリセットすると、ホスト、認証情報、スニペットが削除されます。続行しますか?", + "totpSectionTitle": "二段階認証", + "totpStatusEnabled": "このユーザーに対してTOTPが有効になっています", + "totpStatusDisabled": "このユーザーに対してTOTPは有効になっていません", + "disableTotp": "無効にする", + "disableTotpConfirm": "{{username}}の二段階認証を無効にしてください。パスワードだけでログインできるようになります。", + "totpDisabledSuccess": "TOTPが無効になっています", + "totpDisableFailed": "TOTPの無効化に失敗しました", + "manageApiKeys": "APIキー", + "apiKeyNamePlaceholder": "キー名", + "apiKeyCopyNotice": "このキーを今すぐコピーしてください。二度と表示されません。", + "noApiKeysForUser": "APIキーなし", + "apiKeyDeleteFailed": "APIキーの削除に失敗しました", + "exportUserData": "データエクスポート", + "exportUserDataDesc": "このユーザーのホスト、認証情報、ファイルマネージャーのデータをJSON形式でダウンロードします。秘密情報は復号化されています。", + "exportUserDataSuccess": "ユーザーデータのエクスポート", + "exportUserDataFailed": "ユーザーデータのエクスポートに失敗しました", + "hostsCount": "{{count}} ホスト", + "addHostForUser": "ホストを追加", + "noHostsForUser": "このユーザーにはホストがありません", + "connectToHost": "接続する", + "deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?", + "hostDeletedSuccess": "ホストが削除されました", + "hostDeleteFailed": "ホストの削除に失敗しました", + "credentialsCount": "{{count}} 認証情報", + "addCredentialForUser": "認証情報を追加", + "noCredentialsForUser": "このユーザーには認証情報がありません", + "deleteCredentialConfirm": "{{username}} に属する認証情報 \"{{name}}\" を削除しますか?", + "credentialDeletedSuccess": "認証情報が削除されました", + "credentialDeleteFailed": "認証情報の削除に失敗しました", + "snippetsCount": "{{count}} スニペット", + "addSnippetForUser": "スニペットを追加", + "noSnippetsForUser": "このユーザーにはスニペットがありません", + "snippetRequiredFields": "スニペット名とコンテンツは必須です。", + "snippetNamePlaceholder": "スニペット名", + "snippetContentPlaceholder": "コマンドの内容", + "snippetFolderPlaceholder": "フォルダ(オプション)", + "snippetSaved": "スニペットが保存されました", + "snippetSaveFailed": "スニペットの保存に失敗しました", + "deleteSnippetConfirm": "{{username}} に属するスニペット \"{{name}}\" を削除しますか?", + "snippetDeletedSuccess": "抜粋が削除されました", + "snippetDeleteFailed": "スニペットの削除に失敗しました", + "noSessionsForUser": "アクティブなセッションはありません", + "deleteUserDangerDesc": "{{username}} とそのすべてのデータ(ホスト、認証情報、スニペット、履歴)を完全に削除します。この操作は元に戻せません。", + "deleteUserConfirm": "{{username}} とそのすべてのデータを完全に削除しますか?", + "deleteUserAdminBlocked": "このユーザーを削除する前に、管理者権限を解除してください。", "createRoleRequired": "名前と表示名は必須です。", "createRoleSuccess": "ロール \"{{name}}\" が作成されました", "createRoleFailed": "ロールの作成に失敗しました", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "コマンド履歴", "hostDefaultsCommandHistoryDesc": "デフォルトでは、新しいホストでコマンド履歴を追跡します。", "hostDefaultsSaved": "ホストのデフォルト設定が保存されました", - "hostDefaultsSaveFailed": "ホストのデフォルト設定の保存に失敗しました" + "hostDefaultsSaveFailed": "ホストのデフォルト設定の保存に失敗しました", + "rolePermissions": { + "count": "{{count}} 権限", + "editAction": "編集権限", + "loadError": "権限カタログの読み込みに失敗しました", + "saved": "ロール権限が保存されました", + "saveError": "ロール権限の保存に失敗しました", + "save": "保存", + "saving": "保存中..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "失敗した" }, "userProfile": { + "donateTitle": "Termixを存続させよう", + "donateDescription": "Termixは無料で、セルフホスティング方式で、少数の開発者が余暇を利用して開発しています。もしTermixがあなたの時間や費用を節約できたなら、仮想通貨での寄付が開発継続の助けになります。", + "donateMilestones": "寄付金は、SAML、Kubernetes、およびエージェントのサポート構築に必要な調査や学習に費やす時間を支援するために活用されます。進捗状況を確認し、ぜひご寄付をお願いいたします。", + "donateButton": "仮想通貨を寄付する", "storageModeLocal": "ブラウザ", "storageModeCloud": "データベース", "storageModeDescription": "ブラウザは設定をこのブラウザのみに保存します。データベースはサーバーと同期され、あらゆるデバイスに読み込まれます。", @@ -3011,6 +3144,10 @@ "twoFaOn": "の上", "twoFaOff": "オフ", "versionLabel": "バージョン", + "betaProgramTitle": "ベータプログラム", + "betaProgramDescription": "毎週ローリングされる :beta Docker タグを使用して、今後の新機能をいち早くお試しください。不安定なため、本番環境での使用は推奨しません。", + "betaProgramFeedback": "バグを見つけましたか?こちらから報告してください。", + "betaProgramLearnMore": "もっと詳しく知る", "deleteAccount": "アカウントを削除", "deleteAccountDescription": "アカウントを完全に削除する", "changeServerDescription": "別のTermixバックエンドサーバーに切り替える", diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index 8b301cd5..cbd43435 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Docker가 게스트를 일치시킬 수 있도록 쉼표로 구분된 이름 패턴입니다.", "proxmoxPreferredRanges": "선호하는 IP 범위", "proxmoxPreferredRangesDesc": "게스트 운영체제에 여러 인터페이스가 있는 경우 IP 주소 선택 우선순위를 쉼표로 구분하여 입력합니다.", + "proxmoxAutoSync": "게스트 자동 동기화", + "proxmoxAutoSyncDesc": "주기적으로 이 Proxmox 노드를 검색하고 세션이 잠금 해제된 동안 가져온 게스트 호스트를 생성하거나 업데이트하세요.", + "proxmoxSyncInterval": "동기화 간격(분)", + "proxmoxSyncIntervalDesc": "최소 5분. 스케줄러는 잠긴 사용자 세션을 건너뜁니다.", + "proxmoxMarkMissing": "누락된 손님을 표시하세요", + "proxmoxMarkMissingDesc": "이전에 불러온 게스트가 삭제되는 대신 사라질 경우 proxmox-missing 태그를 추가합니다.", + "proxmoxLastSync": "마지막 동기화", + "proxmoxLastSyncNever": "아직 동기화되지 않았습니다", + "proxmoxLastSyncNoResult": "결과 세부 정보 없음", + "proxmoxLastSyncSummary": "{{created}} 생성됨, {{updated}} 업데이트됨, {{markedMissing}} 누락됨, {{skipped}} 건너뛰어짐", + "proxmoxLastSyncStatus": { + "success": "성공", + "error": "실패한", + "pending": "보류 중" + }, "proxmoxDiscoverAction": "Proxmox 게스트를 검색하고 가져오세요.", "proxmoxImportTitle": "Proxmox에서 가져오기", "proxmoxSelectHost": "Proxmox 호스트를 선택하세요…", @@ -1167,7 +1182,57 @@ "shareHost": "호스트 공유", "shareHostTitle": "공유: {{name}}", "sharing": { - "requiresCredential": "이 호스트에서 공유를 활성화하려면 자격 증명이 필요합니다. 먼저 호스트를 편집하고 자격 증명을 할당하십시오." + "loadError": "공유 데이터를 불러오는 데 실패했습니다. 다시 시도해 주세요.", + "shareWithSection": "공유하기", + "usersTab": "사용자", + "rolesTab": "역할", + "searchPlaceholder": "사용자 또는 역할을 검색하세요...", + "noMatches": "일치하는 항목이 없습니다.", + "permissionLevelLabel": "권한 수준", + "levels": { + "connect": { + "label": "연결하다", + "description": "터미널, 원격 데스크톱, 파일 관리자, 터널 및 Docker 세션만 사용 가능합니다. 호스트 구성에는 접근할 수 없습니다." + }, + "view": { + "label": "보다", + "description": "연결하고 호스트 구성을 확인하세요. 비밀 키는 절대 표시되지 않습니다." + }, + "edit": { + "label": "편집하다", + "description": "호스트를 보고 수정할 수 있습니다. 비밀 키는 교체할 수 있지만 읽을 수는 없으며, 자격 증명 할당은 소유자만 사용할 수 있습니다." + }, + "manage": { + "label": "관리하다", + "description": "호스트를 편집하고, 다른 사람과 공유하고, 권한 수준을 변경하고, 액세스 권한을 취소할 수 있습니다." + } + }, + "expiryLabel": "접근 만료", + "expiry": { + "never": "절대", + "oneHour": "1시간", + "oneDay": "24시간", + "sevenDays": "7일", + "thirtyDays": "30일", + "custom": "관습" + }, + "customHoursPlaceholder": "접속 만료까지 남은 시간", + "shareButton": "공유하다", + "shareWithCount": "공유({{count}})", + "currentAccess": "현재 접근 권한", + "noAccessEntries": "이 호스트는 아직 공유되지 않았습니다.", + "grantedBy": "승인됨", + "expires": "만료됨", + "expired": "만료됨", + "never": "절대", + "revoke": "취소", + "accessUpdated": "액세스 업데이트됨", + "accessUpdateFailed": "접근 권한 업데이트에 실패했습니다.", + "sharedBadge": "공유됨", + "sharedBadgeTooltip": "{{owner}} 님이 공유함 ({{level}} 접근 권한)", + "viewOnlyBanner": "이 호스트는 {{owner}} 님과 보기 권한으로 공유됩니다. 구성은 읽기 전용입니다.", + "sharedEditBanner": "이 호스트는 {{owner}} 님이 편집 권한으로 공유하고 있습니다. 변경 사항은 실제 호스트에 적용되며, 인증 참조는 소유자만 변경할 수 있습니다.", + "ownerOnlyControl": "호스트 소유자만 이 설정을 변경할 수 있습니다." }, "guac": { "connection": "연결", @@ -1302,28 +1367,10 @@ "backspaceKey": "백스페이스 키", "saveHostFirst": "먼저 호스트를 저장하세요.", "sharingOptionsAfterSave": "호스트를 저장한 후에는 공유 옵션을 사용할 수 있습니다.", - "sharingLoadError": "공유 데이터를 불러오는 데 실패했습니다. 연결 상태를 확인하고 다시 시도하세요.", - "shareHostSection": "호스트 공유", - "shareWithUser": "사용자와 공유", - "shareWithRole": "역할과 공유하기", - "selectUser": "사용자 선택", - "selectRole": "역할 선택", - "selectUserOption": "사용자를 선택하세요...", - "selectRoleOption": "역할을 선택하세요...", "permissionLevel": "권한 수준", - "expiresInHours": "(시간) 후에 만료됩니다.", - "noExpiryPlaceholder": "유통기한이 없으려면 빈칸으로 두십시오.", - "shareBtn": "공유하다", - "currentAccess": "현재 액세스", "typeHeader": "유형", "targetHeader": "목표", "permissionHeader": "허가", - "grantedByHeader": "승인자:", - "expiresHeader": "만료됨", - "noAccessEntries": "아직 접근 기록이 없습니다.", - "expiredLabel": "만료됨", - "neverLabel": "절대", - "revokeBtn": "취소", "cancelBtn": "취소", "savingBtn": "절약...", "updateHostBtn": "호스트 업데이트", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "비밀번호 로그인이 비활성화되었습니다. 패스키 또는 외부 인증 공급자를 사용하십시오.", "signInWithPasskey": "패스키로 로그인하세요", "passkeyLoginFailed": "암호키 로그인 실패", - "attemptsRemaining": "남은 시도 횟수 {{count}} 회" + "attemptsRemaining": "남은 시도 횟수 {{count}} 회", + "confirmResetDataWipe": "이 계정은 암호화 업그레이드 이후 로그인한 적이 없으므로 이전 비밀번호 없이는 저장된 데이터를 복구할 수 없습니다. 비밀번호를 재설정하면 호스트, 자격 증명 및 스니펫이 영구적으로 삭제됩니다. 계속하시겠습니까?" }, "hostKey": { "verifyNewHost": "SSH 호스트 키 확인", @@ -2323,6 +2371,13 @@ "serviceLinks": "서비스 링크", "homepagePreview": "홈페이지 미리보기" }, + "donation": { + "title": "Termix를 즐겁게 사용하고 계신가요?", + "body": "Termix는 무료 오픈 소스 소프트웨어로, 두 명의 팀원이 여가 시간을 활용하여 개발 및 유지 관리하고 있습니다. 만약 Termix를 통해 기존에 사용하던 유료 도구를 대체하게 되었다면, 기부를 통해 호스팅 비용을 충당하고 지속적인 개발을 지원할 수 있습니다. 현재는 암호화폐로만 기부가 가능합니다.", + "milestones": "기부금은 SAML 및 Kubernetes 지원과 같은 기능을 구축하는 데 필요한 연구 및 학습 시간을 지원하는 데에도 사용됩니다. 기부 페이지에서 진행 상황을 확인하세요.", + "cta": "기부하세요", + "dismiss": "나중에요" + }, "dashboardTab": { "stable": "안정적인", "hostsOnline": "호스트 온라인", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "관리자 상태 업데이트에 실패했습니다.", "allSessionsRevoked": "모든 세션이 취소되었습니다", "revokeSessionsFailed": "세션 취소에 실패했습니다", + "manageUserData": "사용자 데이터 관리", + "backToUsers": "사용자 목록으로 돌아가기", + "manageTabAccount": "계정", + "manageTabHosts": "호스트", + "manageTabCredentials": "신임장", + "manageTabSnippets": "짧은 발췌", + "manageTabSessions": "세션", + "manageTabDanger": "위험", + "manageEditorBack": "{{username}}의 데이터로 돌아가기", + "dataLockedBadge": "잠김", + "dataLockedNotice": "{{username}}사용자의 데이터는 다음 로그인까지 잠금 상태로 유지됩니다. 그 전까지는 호스트, 자격 증명 및 스니펫을 보거나 편집할 수 없습니다.", + "resetPasswordTitle": "비밀번호 재설정", + "resetPasswordOidcOnly": "이 사용자는 외부 제공업체를 통해 로그인하며 비밀번호가 없습니다.", + "resetPasswordPlaceholder": "새 비밀번호", + "resetPasswordBtn": "다시 놓기", + "resetPasswordWorking": "재설정 중...", + "resetPasswordSuccess": "비밀번호 재설정", + "resetPasswordSuccessWiped": "비밀번호가 재설정되었습니다. 사용자의 암호화된 데이터가 삭제되었습니다.", + "resetPasswordFailed": "비밀번호 재설정에 실패했습니다", + "resetPasswordConfirmWipe": "{{username}} 님은 암호화 업그레이드 이후 로그인하지 않았으므로 데이터를 복구할 수 없습니다. 지금 초기화하면 호스트, 자격 증명 및 스니펫이 삭제됩니다. 계속하시겠습니까?", + "totpSectionTitle": "2단계 인증", + "totpStatusEnabled": "이 사용자에게 TOTP가 활성화되어 있습니다.", + "totpStatusDisabled": "이 사용자에게는 TOTP가 활성화되어 있지 않습니다.", + "disableTotp": "장애를 입히다", + "disableTotpConfirm": "{{username}}에 대한 2단계 인증을 비활성화하시겠습니까? 그러면 해당 사용자는 비밀번호만으로 로그인할 수 있습니다.", + "totpDisabledSuccess": "TOTP 비활성화됨", + "totpDisableFailed": "TOTP를 비활성화하는 데 실패했습니다.", + "manageApiKeys": "API 키", + "apiKeyNamePlaceholder": "키 이름", + "apiKeyCopyNotice": "이 키를 지금 복사하세요. 다시는 표시되지 않습니다.", + "noApiKeysForUser": "API 키 없음", + "apiKeyDeleteFailed": "API 키 삭제에 실패했습니다.", + "exportUserData": "데이터 내보내기", + "exportUserDataDesc": "이 사용자의 호스트, 자격 증명 및 파일 관리자 데이터를 JSON 형식으로 다운로드합니다. 비밀 키는 복호화됩니다.", + "exportUserDataSuccess": "사용자 데이터 내보내기", + "exportUserDataFailed": "사용자 데이터 내보내기에 실패했습니다.", + "hostsCount": "{{count}} 호스트", + "addHostForUser": "호스트 추가", + "noHostsForUser": "이 사용자는 호스트가 없습니다.", + "connectToHost": "연결하다", + "deleteHostConfirm": "{{username}}에 속한 호스트 \"{{name}}\"를 삭제하시겠습니까?", + "hostDeletedSuccess": "호스트가 삭제했습니다", + "hostDeleteFailed": "호스트 삭제에 실패했습니다", + "credentialsCount": "{{count}} 자격 증명", + "addCredentialForUser": "자격 증명 추가", + "noCredentialsForUser": "이 사용자는 자격 증명이 없습니다.", + "deleteCredentialConfirm": "{{username}}에 속한 자격 증명 \"{{name}}\"을 삭제하시겠습니까?", + "credentialDeletedSuccess": "자격 증명이 삭제되었습니다.", + "credentialDeleteFailed": "자격 증명 삭제에 실패했습니다.", + "snippetsCount": "{{count}} 스니펫", + "addSnippetForUser": "코드 조각 추가", + "noSnippetsForUser": "이 사용자는 스니펫이 없습니다.", + "snippetRequiredFields": "스니펫 이름과 내용은 필수 입력 사항입니다.", + "snippetNamePlaceholder": "스니펫 이름", + "snippetContentPlaceholder": "명령 내용", + "snippetFolderPlaceholder": "폴더 (선택 사항)", + "snippetSaved": "코드 조각이 저장되었습니다.", + "snippetSaveFailed": "코드 조각을 저장하는 데 실패했습니다.", + "deleteSnippetConfirm": "{{username}}에 속하는 코드 조각 \"{{name}}\"을 삭제하시겠습니까?", + "snippetDeletedSuccess": "일부 내용이 삭제되었습니다.", + "snippetDeleteFailed": "코드 조각을 삭제하는 데 실패했습니다.", + "noSessionsForUser": "활성 세션 없음", + "deleteUserDangerDesc": "{{username}} 및 해당 데이터(호스트, 자격 증명, 스니펫, 기록)를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "deleteUserConfirm": "{{username}} 와 그 모든 데이터를 영구적으로 삭제하시겠습니까?", + "deleteUserAdminBlocked": "이 사용자를 삭제하기 전에 관리자 권한을 제거하십시오.", "createRoleRequired": "이름과 표시 이름은 필수 입력 사항입니다.", "createRoleSuccess": "역할 \"{{name}}\"이 생성되었습니다", "createRoleFailed": "역할 생성에 실패했습니다", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "명령 기록", "hostDefaultsCommandHistoryDesc": "새 호스트에서 기본적으로 명령 기록을 추적합니다.", "hostDefaultsSaved": "호스트 기본 설정이 저장되었습니다.", - "hostDefaultsSaveFailed": "호스트 기본 설정을 저장하는 데 실패했습니다." + "hostDefaultsSaveFailed": "호스트 기본 설정을 저장하는 데 실패했습니다.", + "rolePermissions": { + "count": "{{count}} 권한", + "editAction": "편집 권한", + "loadError": "권한 카탈로그를 불러오는 데 실패했습니다.", + "saved": "역할 권한이 저장되었습니다", + "saveError": "역할 권한 저장에 실패했습니다.", + "save": "구하다", + "saving": "절약..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "실패한" }, "userProfile": { + "donateTitle": "Termix를 계속 유지하세요", + "donateDescription": "Termix는 무료이며 자체 호스팅되고, 몇몇 사람들이 여가 시간을 활용하여 개발했습니다. Termix가 시간이나 비용을 절약하는 데 도움이 되었다면 암호화폐 기부를 통해 지속적인 운영을 지원해 주세요.", + "donateMilestones": "기부금은 SAML, Kubernetes 및 에이전트 지원 구축에 필요한 연구 및 학습 시간을 지원하는 데 사용됩니다. 진행 상황을 확인하고 기부해 주세요.", + "donateButton": "암호화폐 기부", "storageModeLocal": "브라우저", "storageModeCloud": "데이터 베이스", "storageModeDescription": "브라우저는 설정을 해당 브라우저에만 저장합니다. 데이터베이스는 서버와 동기화되어 모든 기기에서 불러올 수 있습니다.", @@ -3011,6 +3144,10 @@ "twoFaOn": "~에", "twoFaOff": "끄다", "versionLabel": "버전", + "betaProgramTitle": "베타 프로그램", + "betaProgramDescription": "매주 업데이트되는 `:beta` Docker 태그를 사용하여 출시 예정인 기능을 미리 사용해 보세요. 불안정한 환경이므로 프로덕션 환경에는 적합하지 않습니다.", + "betaProgramFeedback": "버그를 발견하셨나요? 여기에서 신고해 주세요.", + "betaProgramLearnMore": "더 알아보기", "deleteAccount": "계정 삭제", "deleteAccountDescription": "계정을 영구적으로 삭제하세요", "changeServerDescription": "다른 Termix 백엔드 서버로 전환하세요", diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index 9a4482fa..f70719a9 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Door komma's gescheiden naampatronen waarmee Docker gastsystemen kan herkennen.", "proxmoxPreferredRanges": "Voorkeurs-IP-bereiken", "proxmoxPreferredRangesDesc": "Door komma's gescheiden voorvoegsels in prioriteitsvolgorde voor IP-selectie wanneer een gast meerdere interfaces heeft.", + "proxmoxAutoSync": "Automatische synchronisatie van gasten", + "proxmoxAutoSyncDesc": "Ontdek periodiek dit Proxmox-knooppunt en maak of update geïmporteerde gasthosts terwijl uw sessie ontgrendeld is.", + "proxmoxSyncInterval": "Synchronisatie-interval (minuten)", + "proxmoxSyncIntervalDesc": "Minimaal 5 minuten. De planner slaat vergrendelde gebruikerssessies over.", + "proxmoxMarkMissing": "Markeer ontbrekende gasten", + "proxmoxMarkMissingDesc": "Voeg een 'proxmox-missing'-tag toe wanneer een eerder geïmporteerde gast verdwijnt, in plaats van deze te verwijderen.", + "proxmoxLastSync": "Laatste synchronisatie", + "proxmoxLastSyncNever": "Nog niet gesynchroniseerd", + "proxmoxLastSyncNoResult": "Geen resultaatdetails", + "proxmoxLastSyncSummary": "{{created}} aangemaakt, {{updated}} bijgewerkt, {{markedMissing}} ontbreekt, {{skipped}} overgeslagen", + "proxmoxLastSyncStatus": { + "success": "Succes", + "error": "Mislukt", + "pending": "In behandeling" + }, "proxmoxDiscoverAction": "Ontdek en importeer Proxmox-gasten", "proxmoxImportTitle": "Importeren vanuit Proxmox", "proxmoxSelectHost": "Selecteer een Proxmox-host…", @@ -1167,7 +1182,57 @@ "shareHost": "Deel host", "shareHostTitle": "Delen: {{name}}", "sharing": { - "requiresCredential": "Deze host moet een inloggegeven gebruiken om delen mogelijk te maken. Bewerk eerst de host en wijs een inloggegeven toe." + "loadError": "Het laden van de deelgegevens is mislukt. Probeer het opnieuw.", + "shareWithSection": "Delen met", + "usersTab": "Gebruikers", + "rolesTab": "Rollen", + "searchPlaceholder": "Gebruikers of rollen zoeken...", + "noMatches": "Geen overeenkomsten gevonden", + "permissionLevelLabel": "Toegangsniveau", + "levels": { + "connect": { + "label": "Verbinden", + "description": "Alleen open sessies: terminal, extern bureaublad, bestandsbeheer, tunnels en Docker. Geen toegang tot de hostconfiguratie." + }, + "view": { + "label": "Weergave", + "description": "Maak verbinding en bekijk de hostconfiguratie. Geheime gegevens worden nooit weergegeven." + }, + "edit": { + "label": "Bewerking", + "description": "Bekijk en wijzig de host. Geheimen kunnen worden vervangen, maar nooit gelezen; toegangsrechten blijven alleen voor de eigenaar." + }, + "manage": { + "label": "Beheren", + "description": "Bewerken, de host delen met anderen, toegangsrechten wijzigen en toegang intrekken." + } + }, + "expiryLabel": "Toegang verloopt", + "expiry": { + "never": "Nooit", + "oneHour": "1 uur", + "oneDay": "24 uur", + "sevenDays": "7 dagen", + "thirtyDays": "30 dagen", + "custom": "Aangepast" + }, + "customHoursPlaceholder": "Aantal uren tot de toegang verloopt", + "shareButton": "Deel", + "shareWithCount": "Delen ({{count}})", + "currentAccess": "Huidige toegang", + "noAccessEntries": "Deze host is nog niet gedeeld.", + "grantedBy": "Toegekend door", + "expires": "Verloopt", + "expired": "Verlopen", + "never": "Nooit", + "revoke": "Herroepen", + "accessUpdated": "Toegang bijgewerkt", + "accessUpdateFailed": "Toegang kon niet worden bijgewerkt.", + "sharedBadge": "Gedeeld", + "sharedBadgeTooltip": "Gedeeld door {{owner}} ({{level}} toegang)", + "viewOnlyBanner": "Deze host wordt met u gedeeld door {{owner}} met leesrechten. De configuratie is alleen-lezen.", + "sharedEditBanner": "Deze host wordt met u gedeeld door {{owner}} met bewerkingsrechten. Wijzigingen zijn van toepassing op de daadwerkelijke host; authenticatiegegevens kunnen alleen door de eigenaar worden gewijzigd.", + "ownerOnlyControl": "Alleen de eigenaar van de host kan dit wijzigen." }, "guac": { "connection": "Verbinding", @@ -1302,28 +1367,10 @@ "backspaceKey": "Backspace-toets", "saveHostFirst": "Sla eerst de host op.", "sharingOptionsAfterSave": "Deelopties zijn beschikbaar nadat de host is opgeslagen.", - "sharingLoadError": "Het laden van de deelgegevens is mislukt. Controleer uw verbinding en probeer het opnieuw.", - "shareHostSection": "Deel host", - "shareWithUser": "Delen met gebruiker", - "shareWithRole": "Delen met Role", - "selectUser": "Gebruiker selecteren", - "selectRole": "Selecteer rol", - "selectUserOption": "Selecteer een gebruiker...", - "selectRoleOption": "Kies een rol...", "permissionLevel": "Toegangsniveau", - "expiresInHours": "Verloopt over (uren)", - "noExpiryPlaceholder": "Laat dit veld leeg voor onbeperkte houdbaarheid.", - "shareBtn": "Deel", - "currentAccess": "Huidige toegang", "typeHeader": "Type", "targetHeader": "Doel", "permissionHeader": "Toestemming", - "grantedByHeader": "Toegekend door", - "expiresHeader": "Verloopt", - "noAccessEntries": "Nog geen toegangsgegevens beschikbaar.", - "expiredLabel": "Verlopen", - "neverLabel": "Nooit", - "revokeBtn": "Herroepen", "cancelBtn": "Annuleren", "savingBtn": "Besparing...", "updateHostBtn": "Update Host", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Inloggen met wachtwoord is uitgeschakeld. Gebruik een toegangscode of een externe authenticatieprovider.", "signInWithPasskey": "Aanmelden met wachtwoord", "passkeyLoginFailed": "Aanmelden met wachtwoord mislukt", - "attemptsRemaining": "{{count}} resterende pogingen" + "attemptsRemaining": "{{count}} resterende pogingen", + "confirmResetDataWipe": "Dit account is sinds de upgrade van de encryptie niet meer gebruikt, waardoor de opgeslagen gegevens niet kunnen worden hersteld zonder het oude wachtwoord. Door het wachtwoord opnieuw in te stellen, worden de hosts, inloggegevens en codefragmenten permanent verwijderd. Doorgaan?" }, "hostKey": { "verifyNewHost": "Verifieer de SSH-hostsleutel", @@ -2323,6 +2371,13 @@ "serviceLinks": "Servicelinks", "homepagePreview": "Startpagina-voorbeeld" }, + "donation": { + "title": "Vind je Termix lekker?", + "body": "Termix is gratis en open source, ontwikkeld en onderhouden door een team van twee personen in hun vrije tijd. Als het een commerciële tool heeft vervangen waar je anders voor zou betalen, helpt een donatie om de hostingkosten te dekken en de ontwikkeling voort te zetten. Op dit moment accepteren we alleen donaties in cryptovaluta.", + "milestones": "Donaties helpen ook bij het financieren van de tijd die nodig is voor onderzoek en het leren van wat vereist is om functies zoals SAML- en Kubernetes-ondersteuning te ontwikkelen. Bekijk de voortgang op de donatiepagina.", + "cta": "Doneer", + "dismiss": "Misschien later" + }, "dashboardTab": { "stable": "STABIEL", "hostsOnline": "Hosts Online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Het bijwerken van de beheerdersstatus is mislukt.", "allSessionsRevoked": "Alle sessies zijn ingetrokken.", "revokeSessionsFailed": "Sessies konden niet worden ingetrokken.", + "manageUserData": "Gebruikersgegevens beheren", + "backToUsers": "Terug naar gebruikers", + "manageTabAccount": "Rekening", + "manageTabHosts": "Gastheren", + "manageTabCredentials": "Referenties", + "manageTabSnippets": "Fragmenten", + "manageTabSessions": "Sessies", + "manageTabDanger": "Gevaar", + "manageEditorBack": "Terug naar de gegevens van {{username}}", + "dataLockedBadge": "VERGRENDELD", + "dataLockedNotice": "De gegevens van {{username}}blijven vergrendeld tot de volgende keer dat ze inloggen. Hun hosts, inloggegevens en codefragmenten kunnen tot die tijd niet worden bekeken of bewerkt.", + "resetPasswordTitle": "Wachtwoord opnieuw instellen", + "resetPasswordOidcOnly": "Deze gebruiker meldt zich aan via een externe provider en heeft geen wachtwoord.", + "resetPasswordPlaceholder": "Nieuw wachtwoord", + "resetPasswordBtn": "Reset", + "resetPasswordWorking": "Opnieuw instellen...", + "resetPasswordSuccess": "Wachtwoord opnieuw instellen", + "resetPasswordSuccessWiped": "Wachtwoord gereset. De versleutelde gegevens van de gebruiker zijn gewist.", + "resetPasswordFailed": "Wachtwoord opnieuw instellen mislukt", + "resetPasswordConfirmWipe": "{{username}} heeft niet meer ingelogd sinds de upgrade van de encryptie, waardoor hun gegevens niet kunnen worden hersteld. Door nu opnieuw in te stellen, worden hun hosts, inloggegevens en codefragmenten verwijderd. Doorgaan?", + "totpSectionTitle": "Tweefactorauthenticatie", + "totpStatusEnabled": "TOTP is ingeschakeld voor deze gebruiker.", + "totpStatusDisabled": "TOTP is niet ingeschakeld voor deze gebruiker.", + "disableTotp": "Uitzetten", + "disableTotpConfirm": "Schakel tweefactorauthenticatie uit voor {{username}}? Zij kunnen dan inloggen met alleen hun wachtwoord.", + "totpDisabledSuccess": "TOTP uitgeschakeld", + "totpDisableFailed": "TOTP uitschakelen is mislukt.", + "manageApiKeys": "API-sleutels", + "apiKeyNamePlaceholder": "Sleutelnaam", + "apiKeyCopyNotice": "Kopieer deze sleutel nu, hij wordt niet meer weergegeven.", + "noApiKeysForUser": "Geen API-sleutels", + "apiKeyDeleteFailed": "Het verwijderen van de API-sleutel is mislukt.", + "exportUserData": "Gegevens exporteren", + "exportUserDataDesc": "Download de hosts, inloggegevens en bestandsbeheergegevens van deze gebruiker als JSON. Geheimen zijn gedecodeerd.", + "exportUserDataSuccess": "Gebruikersgegevens geëxporteerd", + "exportUserDataFailed": "Exporteren van gebruikersgegevens is mislukt.", + "hostsCount": "{{count}} hosts", + "addHostForUser": "Voeg een host toe", + "noHostsForUser": "Deze gebruiker heeft geen hosts.", + "connectToHost": "Verbinden", + "deleteHostConfirm": "Host \"{{name}}\" verwijderen die bij {{username}} hoort?", + "hostDeletedSuccess": "Host verwijderd", + "hostDeleteFailed": "Het verwijderen van de host is mislukt.", + "credentialsCount": "{{count}} inloggegevens", + "addCredentialForUser": "Voeg inloggegevens toe", + "noCredentialsForUser": "Deze gebruiker heeft geen inloggegevens.", + "deleteCredentialConfirm": "Verwijder de inloggegevens \"{{name}}\" die bij {{username}} horen.", + "credentialDeletedSuccess": "Inloggegevens verwijderd", + "credentialDeleteFailed": "Het verwijderen van de inloggegevens is mislukt.", + "snippetsCount": "{{count}} fragmenten", + "addSnippetForUser": "Voeg codefragment toe", + "noSnippetsForUser": "Deze gebruiker heeft geen codefragmenten.", + "snippetRequiredFields": "De naam en inhoud van het codefragment zijn verplicht.", + "snippetNamePlaceholder": "Fragmentnaam", + "snippetContentPlaceholder": "Opdrachtinhoud", + "snippetFolderPlaceholder": "Map (optioneel)", + "snippetSaved": "Fragment opgeslagen", + "snippetSaveFailed": "Het opslaan van het codefragment is mislukt.", + "deleteSnippetConfirm": "Verwijder het fragment \"{{name}}\" dat bij {{username}} hoort.", + "snippetDeletedSuccess": "Fragment verwijderd", + "snippetDeleteFailed": "Het verwijderen van het fragment is mislukt.", + "noSessionsForUser": "Geen actieve sessies", + "deleteUserDangerDesc": "Verwijder {{username}} en alle bijbehorende gegevens (hosts, inloggegevens, codefragmenten, geschiedenis) permanent. Deze actie kan niet ongedaan worden gemaakt.", + "deleteUserConfirm": "Wil je {{username}} en al hun gegevens permanent verwijderen?", + "deleteUserAdminBlocked": "Verwijder de beheerdersstatus voordat u deze gebruiker verwijdert.", "createRoleRequired": "Naam en weergavenaam zijn verplicht.", "createRoleSuccess": "Rol \"{{name}}\" aangemaakt", "createRoleFailed": "Het aanmaken van de rol is mislukt.", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Commandogeschiedenis", "hostDefaultsCommandHistoryDesc": "Volg standaard de opdrachtgeschiedenis op nieuwe hosts.", "hostDefaultsSaved": "Host-standaardinstellingen opgeslagen", - "hostDefaultsSaveFailed": "Het opslaan van de hoststandaardinstellingen is mislukt." + "hostDefaultsSaveFailed": "Het opslaan van de hoststandaardinstellingen is mislukt.", + "rolePermissions": { + "count": "{{count}} machtigingen", + "editAction": "Machtigingen bewerken", + "loadError": "Het laden van de machtigingencatalogus is mislukt.", + "saved": "Rolmachtigingen opgeslagen", + "saveError": "Het opslaan van rolmachtigingen is mislukt.", + "save": "Redden", + "saving": "Besparing..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Mislukt" }, "userProfile": { + "donateTitle": "Houd Termix in leven", + "donateDescription": "Termix is gratis, zelf te hosten en is ontwikkeld door slechts een paar mensen in hun vrije tijd. Als het je tijd of geld heeft bespaard, helpt een cryptodonatie om het project draaiende te houden.", + "donateMilestones": "Donaties helpen ons de tijd te financieren die nodig is voor onderzoek en het leren van de benodigde kennis om SAML-, Kubernetes- en Agent-ondersteuning te ontwikkelen. Bekijk de voortgang en doneer.", + "donateButton": "Doneer crypto", "storageModeLocal": "Browser", "storageModeCloud": "Database", "storageModeDescription": "De browser slaat instellingen alleen in deze browser op. De database synchroniseert met de server en laadt op elk apparaat.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Op", "twoFaOff": "Uit", "versionLabel": "Versie", + "betaProgramTitle": "Bèta-programma", + "betaProgramDescription": "Test aankomende functies vroegtijdig met de wekelijkse, doorlopende :beta Docker-tag. Deze is niet stabiel en niet geschikt voor productie.", + "betaProgramFeedback": "Een bug gevonden? Meld hem hier.", + "betaProgramLearnMore": "Meer informatie", "deleteAccount": "Account verwijderen", "deleteAccountDescription": "Verwijder je account permanent", "changeServerDescription": "Schakel over naar een andere Termix-backendserver.", diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index e1f91b3e..bf625ac5 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Kommaseparerte navnemønstre som gjør det mulig for Docker å matche gjester", "proxmoxPreferredRanges": "Foretrukne IP-områder", "proxmoxPreferredRangesDesc": "Kommaseparerte prefikser i prioritert rekkefølge for IP-valg når en gjest har flere grensesnitt", + "proxmoxAutoSync": "Automatisk synkronisering av gjester", + "proxmoxAutoSyncDesc": "Oppdag denne Proxmox-noden med jevne mellomrom og opprett eller oppdater importerte gjesteverter mens økten din er ulåst.", + "proxmoxSyncInterval": "Synkroniseringsintervall (minutter)", + "proxmoxSyncIntervalDesc": "Minimum 5 minutter. Planleggeren hopper over låste brukerøkter.", + "proxmoxMarkMissing": "Merk manglende gjester", + "proxmoxMarkMissingDesc": "Legg til en proxmox-missing-tag når en tidligere importert gjest forsvinner i stedet for å slette den.", + "proxmoxLastSync": "Siste synkronisering", + "proxmoxLastSyncNever": "Ikke synkronisert ennå", + "proxmoxLastSyncNoResult": "Ingen resultatdetaljer", + "proxmoxLastSyncSummary": "{{created}} opprettet, {{updated}} oppdatert, {{markedMissing}} mangler, {{skipped}} hoppet over", + "proxmoxLastSyncStatus": { + "success": "Suksess", + "error": "Mislyktes", + "pending": "I påvente av" + }, "proxmoxDiscoverAction": "Oppdag og importer Proxmox-gjester", "proxmoxImportTitle": "Importer fra Proxmox", "proxmoxSelectHost": "Velg en Proxmox-vert…", @@ -1167,7 +1182,57 @@ "shareHost": "Del vert", "shareHostTitle": "Del: {{name}}", "sharing": { - "requiresCredential": "Denne verten må bruke en legitimasjon for å aktivere deling. Rediger verten og tilordne en legitimasjon først." + "loadError": "Kunne ikke laste inn delingsdataene. Prøv på nytt.", + "shareWithSection": "Del med", + "usersTab": "Brukere", + "rolesTab": "Roller", + "searchPlaceholder": "Søk etter brukere eller roller...", + "noMatches": "Ingen treff funnet", + "permissionLevelLabel": "Tillatelsesnivå", + "levels": { + "connect": { + "label": "Koble til", + "description": "Kun åpne økter: terminal, eksternt skrivebord, filbehandler, tunneler og Docker. Ingen tilgang til vertskonfigurasjonen." + }, + "view": { + "label": "Utsikt", + "description": "Koble til, pluss se vertskonfigurasjonen. Hemmeligheter vises aldri." + }, + "edit": { + "label": "Redigere", + "description": "Vis og endre verten. Hemmeligheter kan erstattes, men aldri leses; tildeling av legitimasjon forblir kun for eieren." + }, + "manage": { + "label": "Administrer", + "description": "Rediger, pluss del verten med andre, endre tillatelsesnivåer og tilbakekall tilgang." + } + }, + "expiryLabel": "Utløpsdato for tilgang", + "expiry": { + "never": "Aldri", + "oneHour": "1 time", + "oneDay": "24 timer", + "sevenDays": "7 dager", + "thirtyDays": "30 dager", + "custom": "Skikk" + }, + "customHoursPlaceholder": "Timer til tilgangen utløper", + "shareButton": "Dele", + "shareWithCount": "Del ({{count}})", + "currentAccess": "Nåværende tilgang", + "noAccessEntries": "Denne verten har ikke blitt delt ennå", + "grantedBy": "Tildelt av", + "expires": "Utløper", + "expired": "Utløpt", + "never": "Aldri", + "revoke": "Tilbakekalling", + "accessUpdated": "Tilgang oppdatert", + "accessUpdateFailed": "Kunne ikke oppdatere tilgangen", + "sharedBadge": "Delt", + "sharedBadgeTooltip": "Delt av {{owner}} ({{level}} tilgang)", + "viewOnlyBanner": "Denne verten deles med deg av {{owner}} med lesetilgang. Konfigurasjonen er skrivebeskyttet.", + "sharedEditBanner": "Denne verten deles med deg av {{owner}} med redigeringstilgang. Endringene gjelder for den virkelige verten; autentiseringsreferanser kan bare endres av eieren.", + "ownerOnlyControl": "Bare vertseieren kan endre dette" }, "guac": { "connection": "Forbindelse", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tilbaketast", "saveHostFirst": "Lagre verten først.", "sharingOptionsAfterSave": "Delingsalternativer er tilgjengelige etter at verten er lagret.", - "sharingLoadError": "Kunne ikke laste inn delingsdataene. Sjekk tilkoblingen din og prøv på nytt.", - "shareHostSection": "Del vert", - "shareWithUser": "Del med bruker", - "shareWithRole": "Del med rolle", - "selectUser": "Velg bruker", - "selectRole": "Velg rolle", - "selectUserOption": "Velg en bruker...", - "selectRoleOption": "Velg en rolle...", "permissionLevel": "Tillatelsesnivå", - "expiresInHours": "Utløper om (timer)", - "noExpiryPlaceholder": "La stå tomt hvis det ikke er utløpt", - "shareBtn": "Dele", - "currentAccess": "Nåværende tilgang", "typeHeader": "Type", "targetHeader": "Mål", "permissionHeader": "Tillatelse", - "grantedByHeader": "Tildelt av", - "expiresHeader": "Utløper", - "noAccessEntries": "Ingen tilgangsoppføringer ennå.", - "expiredLabel": "Utløpt", - "neverLabel": "Aldri", - "revokeBtn": "Tilbakekalling", "cancelBtn": "Kansellere", "savingBtn": "Lagrer...", "updateHostBtn": "Oppdater vert", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Passordpålogging er deaktivert. Bruk en passordnøkkel eller en ekstern autentiseringsleverandør.", "signInWithPasskey": "Logg inn med passord", "passkeyLoginFailed": "Innlogging med passord mislyktes", - "attemptsRemaining": "{{count}} forsøk igjen" + "attemptsRemaining": "{{count}} forsøk igjen", + "confirmResetDataWipe": "Denne kontoen har ikke vært logget inn siden krypteringsoppgraderingen, så de lagrede dataene kan ikke gjenopprettes uten det gamle passordet. Tilbakestilling vil slette vertene, påloggingsinformasjonen og kodebitene permanent. Vil du fortsette?" }, "hostKey": { "verifyNewHost": "Bekreft SSH-vertsnøkkel", @@ -2323,6 +2371,13 @@ "serviceLinks": "Tjenestelenker", "homepagePreview": "Forhåndsvisning av hjemmeside" }, + "donation": { + "title": "Liker du Termix?", + "body": "Termix er gratis og åpen kildekode, bygget og vedlikeholdt av et team på to personer på fritiden. Hvis det erstatter et kommersielt verktøy du ellers ville betalt for, bidrar en donasjon til å dekke hostingkostnader og holde utviklingen i gang. Per nå er donasjoner kun kryptovaluta.", + "milestones": "Donasjoner bidrar også til å finansiere tiden som er nødvendig for å undersøke og lære hva som trengs for å bygge funksjoner som SAML og Kubernetes-støtte. Se fremdriften på donasjonssiden.", + "cta": "Donere", + "dismiss": "Kanskje senere" + }, "dashboardTab": { "stable": "STABIL", "hostsOnline": "Verter på nett", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Kunne ikke oppdatere administratorstatusen", "allSessionsRevoked": "Alle økter tilbakekalt", "revokeSessionsFailed": "Kunne ikke tilbakekalle økter", + "manageUserData": "Administrer brukerdata", + "backToUsers": "Tilbake til brukere", + "manageTabAccount": "Konto", + "manageTabHosts": "Verter", + "manageTabCredentials": "Legitimasjon", + "manageTabSnippets": "Utdrag", + "manageTabSessions": "Økter", + "manageTabDanger": "Fare", + "manageEditorBack": "Tilbake til dataene til {{username}}", + "dataLockedBadge": "LÅST", + "dataLockedNotice": "{{username}}sine data forblir låst til de logger seg inn igjen. Vertene, påloggingsinformasjonen og kodeutdragene deres kan ikke vises eller redigeres før da.", + "resetPasswordTitle": "Tilbakestill passord", + "resetPasswordOidcOnly": "Denne brukeren logger seg på via en ekstern leverandør og har ikke noe passord.", + "resetPasswordPlaceholder": "Nytt passord", + "resetPasswordBtn": "Tilbakestill", + "resetPasswordWorking": "Tilbakestiller...", + "resetPasswordSuccess": "Tilbakestilling av passord", + "resetPasswordSuccessWiped": "Tilbakestilling av passord. Brukerens krypterte data ble slettet.", + "resetPasswordFailed": "Kunne ikke tilbakestille passordet", + "resetPasswordConfirmWipe": "{{username}} har ikke logget inn siden krypteringsoppgraderingen, så dataene deres kan ikke gjenopprettes. Tilbakestilling nå vil slette vertene, påloggingsinformasjonen og kodebitene deres. Vil du fortsette?", + "totpSectionTitle": "Tofaktorautentisering", + "totpStatusEnabled": "TOTP er aktivert for denne brukeren", + "totpStatusDisabled": "TOTP er ikke aktivert for denne brukeren", + "disableTotp": "Deaktiver", + "disableTotpConfirm": "Deaktiver tofaktorautentisering for {{username}}? De vil kunne logge inn med bare passordet sitt.", + "totpDisabledSuccess": "TOTP deaktivert", + "totpDisableFailed": "Kunne ikke deaktivere TOTP", + "manageApiKeys": "API-nøkler", + "apiKeyNamePlaceholder": "Nøkkelnavn", + "apiKeyCopyNotice": "Kopier denne nøkkelen nå, den vil ikke vises igjen.", + "noApiKeysForUser": "Ingen API-nøkler", + "apiKeyDeleteFailed": "Kunne ikke slette API-nøkkelen", + "exportUserData": "Dataeksport", + "exportUserDataDesc": "Last ned denne brukerens verter, påloggingsinformasjon og filbehandlingsdata som JSON. Hemmelighetene dekrypteres.", + "exportUserDataSuccess": "Brukerdata eksportert", + "exportUserDataFailed": "Kunne ikke eksportere brukerdata", + "hostsCount": "{{count}} verter", + "addHostForUser": "Legg til vert", + "noHostsForUser": "Denne brukeren har ingen verter", + "connectToHost": "Koble til", + "deleteHostConfirm": "Slett verten \"{{name}}\" som tilhører {{username}}?", + "hostDeletedSuccess": "Verten er slettet", + "hostDeleteFailed": "Kunne ikke slette verten", + "credentialsCount": "{{count}} legitimasjon", + "addCredentialForUser": "Legg til legitimasjon", + "noCredentialsForUser": "Denne brukeren har ingen påloggingsinformasjon", + "deleteCredentialConfirm": "Slett legitimasjonen «{{name}}» som tilhører {{username}}?", + "credentialDeletedSuccess": "Legitimasjon slettet", + "credentialDeleteFailed": "Kunne ikke slette legitimasjonen", + "snippetsCount": "{{count}} utdrag", + "addSnippetForUser": "Legg til utdrag", + "noSnippetsForUser": "Denne brukeren har ingen utdrag", + "snippetRequiredFields": "Navn og innhold på utdrag er obligatorisk", + "snippetNamePlaceholder": "Navn på utdrag", + "snippetContentPlaceholder": "Kommandoinnhold", + "snippetFolderPlaceholder": "Mappe (valgfritt)", + "snippetSaved": "Kodestykket er lagret", + "snippetSaveFailed": "Kunne ikke lagre utdraget", + "deleteSnippetConfirm": "Slett utdraget «{{name}}» som tilhører {{username}}?", + "snippetDeletedSuccess": "Utdrag slettet", + "snippetDeleteFailed": "Kunne ikke slette utdraget", + "noSessionsForUser": "Ingen aktive økter", + "deleteUserDangerDesc": "Slett {{username}} og alle dataene deres (verter, påloggingsinformasjon, kodesnutter, historikk) permanent. Dette kan ikke angres.", + "deleteUserConfirm": "Vil du slette {{username}} og alle dataene deres permanent?", + "deleteUserAdminBlocked": "Fjern administratorstatusen før du sletter denne brukeren.", "createRoleRequired": "Navn og visningsnavn er obligatorisk", "createRoleSuccess": "Rollen «{{name}}» ble opprettet", "createRoleFailed": "Kunne ikke opprette rollen", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Kommandohistorikk", "hostDefaultsCommandHistoryDesc": "Spor kommandohistorikk på nye verter som standard", "hostDefaultsSaved": "Vertsstandarder lagret", - "hostDefaultsSaveFailed": "Kunne ikke lagre standardverdiene for verten" + "hostDefaultsSaveFailed": "Kunne ikke lagre standardverdiene for verten", + "rolePermissions": { + "count": "{{count}} tillatelser", + "editAction": "Rediger tillatelser", + "loadError": "Kunne ikke laste inn tillatelseskatalogen", + "saved": "Rolletillatelser lagret", + "saveError": "Kunne ikke lagre rolletillatelser", + "save": "Spare", + "saving": "Lagrer..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Mislyktes" }, "userProfile": { + "donateTitle": "Hold Termix i live", + "donateDescription": "Termix er gratis, selvhostet og bygget av bare noen få personer på fritiden. Hvis det har spart deg tid eller penger, bidrar en kryptodonasjon til å holde det gående.", + "donateMilestones": "Donasjoner bidrar til å finansiere tiden som er nødvendig for å undersøke og lære hva som trengs for å bygge SAML-, Kubernetes- og agentstøtte. Se fremdriften og doner.", + "donateButton": "Doner krypto", "storageModeLocal": "Nettleser", "storageModeCloud": "Database", "storageModeDescription": "Nettleseren lagrer innstillinger bare i denne nettleseren. Databasen synkroniseres med serveren og lastes inn på en hvilken som helst enhet.", @@ -3011,6 +3144,10 @@ "twoFaOn": "På", "twoFaOff": "Av", "versionLabel": "Versjon", + "betaProgramTitle": "Betaprogram", + "betaProgramDescription": "Prøv kommende funksjoner tidlig med den ukentlige rullerende :beta Docker-taggen. Ustabil, ikke for produksjon.", + "betaProgramFeedback": "Fant du en feil? Rapporter den her.", + "betaProgramLearnMore": "Lær mer", "deleteAccount": "Slett konto", "deleteAccountDescription": "Slett kontoen din permanent", "changeServerDescription": "Bytt til en annen Termix-backend-server", diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index 3f6454c4..c784e101 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Wzorce nazw rozdzielone przecinkami, które umożliwiają Dockerowi dopasowywanie gości", "proxmoxPreferredRanges": "Preferowane zakresy adresów IP", "proxmoxPreferredRangesDesc": "Prefiksy rozdzielone przecinkami w kolejności priorytetów wyboru adresu IP, gdy gość ma wiele interfejsów", + "proxmoxAutoSync": "Automatyczna synchronizacja gości", + "proxmoxAutoSyncDesc": "Okresowo wykrywaj ten węzeł Proxmox i twórz lub aktualizuj importowane hosty gościnne, gdy Twoja sesja jest odblokowana.", + "proxmoxSyncInterval": "Interwał synchronizacji (minuty)", + "proxmoxSyncIntervalDesc": "Minimum 5 minut. Harmonogram pomija zablokowane sesje użytkowników.", + "proxmoxMarkMissing": "Oznacz brakujących gości", + "proxmoxMarkMissingDesc": "Dodaj znacznik proxmox-missing, gdy wcześniej zaimportowany gość zniknie, zamiast go usuwać.", + "proxmoxLastSync": "Ostatnia synchronizacja", + "proxmoxLastSyncNever": "Jeszcze nie zsynchronizowano", + "proxmoxLastSyncNoResult": "Brak szczegółów wyników", + "proxmoxLastSyncSummary": "{{created}} utworzono, {{updated}} zaktualizowano, {{markedMissing}} brakuje, {{skipped}} pominięto", + "proxmoxLastSyncStatus": { + "success": "Sukces", + "error": "Przegrany", + "pending": "Aż do" + }, "proxmoxDiscoverAction": "Odkryj i importuj gości Proxmox", "proxmoxImportTitle": "Import z Proxmox", "proxmoxSelectHost": "Wybierz hosta Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Udostępnij hosta", "shareHostTitle": "Udostępnij: {{name}}", "sharing": { - "requiresCredential": "Ten host musi użyć danych uwierzytelniających, aby umożliwić udostępnianie. Najpierw edytuj hosta i przypisz dane uwierzytelniające." + "loadError": "Nie udało się załadować udostępnianych danych. Spróbuj ponownie.", + "shareWithSection": "Podziel się z", + "usersTab": "Użytkownicy", + "rolesTab": "Role", + "searchPlaceholder": "Wyszukaj użytkowników lub role...", + "noMatches": "Nie znaleziono wyników", + "permissionLevelLabel": "Poziom uprawnień", + "levels": { + "connect": { + "label": "Łączyć", + "description": "Tylko sesje otwarte: terminal, pulpit zdalny, menedżer plików, tunele i Docker. Brak dostępu do konfiguracji hosta." + }, + "view": { + "label": "Pogląd", + "description": "Połącz się i zobacz konfigurację hosta. Sekrety nigdy nie są widoczne." + }, + "edit": { + "label": "Redagować", + "description": "Wyświetlaj i modyfikuj hosta. Sekrety można zastąpić, ale nigdy odczytać; przypisanie poświadczeń pozostaje przypisane tylko właścicielowi." + }, + "manage": { + "label": "Zarządzać", + "description": "Edytuj, udostępniaj hosta innym osobom, zmieniaj poziomy uprawnień i cofaj dostęp." + } + }, + "expiryLabel": "Wygaśnięcie dostępu", + "expiry": { + "never": "Nigdy", + "oneHour": "1 godzina", + "oneDay": "24 godziny", + "sevenDays": "7 dni", + "thirtyDays": "30 dni", + "custom": "Zwyczaj" + }, + "customHoursPlaceholder": "Godziny do wygaśnięcia dostępu", + "shareButton": "Udział", + "shareWithCount": "Udostępnij ({{count}})", + "currentAccess": "Aktualny dostęp", + "noAccessEntries": "Ten host nie został jeszcze udostępniony", + "grantedBy": "Przyznane przez", + "expires": "Wygasa", + "expired": "Wygasły", + "never": "Nigdy", + "revoke": "Unieważnić", + "accessUpdated": "Dostęp zaktualizowany", + "accessUpdateFailed": "Nie udało się zaktualizować dostępu", + "sharedBadge": "Wspólny", + "sharedBadgeTooltip": "Udostępnione przez {{owner}} (dostęp{{level}})", + "viewOnlyBanner": "Ten host jest udostępniany Tobie przez {{owner}} z dostępem do przeglądania. Konfiguracja jest tylko do odczytu.", + "sharedEditBanner": "Ten host jest udostępniany Tobie przez {{owner}} z dostępem do edycji. Zmiany dotyczą rzeczywistego hosta; referencje uwierzytelniające może zmieniać tylko właściciel.", + "ownerOnlyControl": "Tylko właściciel hosta może to zmienić" }, "guac": { "connection": "Połączenie", @@ -1302,28 +1367,10 @@ "backspaceKey": "Klawisz Backspace", "saveHostFirst": "Najpierw zapisz hosta.", "sharingOptionsAfterSave": "Opcje udostępniania będą dostępne po zapisaniu hosta.", - "sharingLoadError": "Nie udało się załadować udostępnianych danych. Sprawdź połączenie i spróbuj ponownie.", - "shareHostSection": "Udostępnij hosta", - "shareWithUser": "Udostępnij użytkownikowi", - "shareWithRole": "Udostępnij z rolą", - "selectUser": "Wybierz użytkownika", - "selectRole": "Wybierz rolę", - "selectUserOption": "Wybierz użytkownika...", - "selectRoleOption": "Wybierz rolę...", "permissionLevel": "Poziom uprawnień", - "expiresInHours": "Wygasa za (godziny)", - "noExpiryPlaceholder": "Pozostaw puste, aby nie utracić ważności", - "shareBtn": "Udział", - "currentAccess": "Aktualny dostęp", "typeHeader": "Typ", "targetHeader": "Cel", "permissionHeader": "Pozwolenie", - "grantedByHeader": "Przyznane przez", - "expiresHeader": "Wygasa", - "noAccessEntries": "Brak wpisów dostępu.", - "expiredLabel": "Wygasły", - "neverLabel": "Nigdy", - "revokeBtn": "Unieważnić", "cancelBtn": "Anulować", "savingBtn": "Oszczędność...", "updateHostBtn": "Aktualizacja hosta", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Logowanie hasłem jest wyłączone. Użyj klucza dostępu lub zewnętrznego dostawcy uwierzytelniania.", "signInWithPasskey": "Zaloguj się za pomocą klucza dostępu", "passkeyLoginFailed": "Nie udało się zalogować za pomocą klucza dostępu", - "attemptsRemaining": "Pozostało {{count}} prób" + "attemptsRemaining": "Pozostało {{count}} prób", + "confirmResetDataWipe": "To konto nie było logowane od czasu aktualizacji szyfrowania, więc nie można odzyskać zapisanych na nim danych bez starego hasła. Zresetowanie spowoduje trwałe usunięcie hostów, danych logowania i fragmentów kodu. Kontynuować?" }, "hostKey": { "verifyNewHost": "Zweryfikuj klucz hosta SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Linki serwisowe", "homepagePreview": "Podgląd strony głównej" }, + "donation": { + "title": "Podoba Ci się Termix?", + "body": "Termix jest darmowy i ma otwarte oprogramowanie, tworzone i utrzymywane przez dwuosobowy zespół w wolnym czasie. Jeśli zastąpi on komercyjne narzędzie, za które w przeciwnym razie musiałbyś płacić, darowizna pomoże pokryć koszty hostingu i umożliwi dalszy rozwój. Na chwilę obecną darowizny są możliwe wyłącznie w kryptowalutach.", + "milestones": "Darowizny pomagają również sfinansować czas poświęcony na badania i zdobywanie wiedzy niezbędnej do tworzenia funkcji takich jak obsługa SAML i Kubernetes. Postępy można śledzić na stronie darowizn.", + "cta": "Podarować", + "dismiss": "Może później" + }, "dashboardTab": { "stable": "STABILNY", "hostsOnline": "Gospodarze online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Nie udało się zaktualizować statusu administratora", "allSessionsRevoked": "Wszystkie sesje odwołane", "revokeSessionsFailed": "Nie udało się odwołać sesji", + "manageUserData": "Zarządzaj danymi użytkownika", + "backToUsers": "Powrót do użytkowników", + "manageTabAccount": "Konto", + "manageTabHosts": "Zastępy niebieskie", + "manageTabCredentials": "Referencje", + "manageTabSnippets": "Fragmenty", + "manageTabSessions": "Sesje", + "manageTabDanger": "Niebezpieczeństwo", + "manageEditorBack": "Powrót do danych {{username}}", + "dataLockedBadge": "ZAMKNIĘTY", + "dataLockedNotice": "Dane użytkownika {{username}}pozostaną zablokowane do momentu jego następnego zalogowania. Do tego czasu nie będzie można przeglądać ani edytować jego hostów, danych uwierzytelniających i fragmentów kodu.", + "resetPasswordTitle": "Zresetuj hasło", + "resetPasswordOidcOnly": "Ten użytkownik loguje się za pośrednictwem zewnętrznego dostawcy i nie ma hasła.", + "resetPasswordPlaceholder": "Nowe hasło", + "resetPasswordBtn": "Nastawić", + "resetPasswordWorking": "Resetowanie...", + "resetPasswordSuccess": "Resetowanie hasła", + "resetPasswordSuccessWiped": "Resetowanie hasła. Zaszyfrowane dane użytkownika zostały usunięte.", + "resetPasswordFailed": "Nie udało się zresetować hasła", + "resetPasswordConfirmWipe": "{{username}} nie zalogował się od czasu aktualizacji szyfrowania, więc jego danych nie da się odzyskać. Zresetowanie teraz spowoduje usunięcie jego hostów, danych logowania i fragmentów kodu. Kontynuować?", + "totpSectionTitle": "Uwierzytelnianie dwuskładnikowe", + "totpStatusEnabled": "TOTP jest włączony dla tego użytkownika", + "totpStatusDisabled": "TOTP nie jest włączony dla tego użytkownika", + "disableTotp": "Wyłączyć", + "disableTotpConfirm": "Wyłączyć uwierzytelnianie dwuskładnikowe dla {{username}}? Będą mogli się logować, podając tylko swoje hasło.", + "totpDisabledSuccess": "TOTP wyłączony", + "totpDisableFailed": "Nie udało się wyłączyć TOTP", + "manageApiKeys": "Klucze API", + "apiKeyNamePlaceholder": "Nazwa klucza", + "apiKeyCopyNotice": "Skopiuj ten klucz teraz, nie będzie on już więcej wyświetlany.", + "noApiKeysForUser": "Brak kluczy API", + "apiKeyDeleteFailed": "Nie udało się usunąć klucza API", + "exportUserData": "Eksport danych", + "exportUserDataDesc": "Pobierz hosty, dane uwierzytelniające i dane menedżera plików tego użytkownika w formacie JSON. Sekrety są odszyfrowane.", + "exportUserDataSuccess": "Wyeksportowano dane użytkownika", + "exportUserDataFailed": "Nie udało się wyeksportować danych użytkownika", + "hostsCount": "{{count}} gospodarzy", + "addHostForUser": "Dodaj hosta", + "noHostsForUser": "Ten użytkownik nie ma żadnych hostów", + "connectToHost": "Łączyć", + "deleteHostConfirm": "Usunąć hosta „{{name}}” należącego do {{username}}?", + "hostDeletedSuccess": "Usunięto hosta", + "hostDeleteFailed": "Nie udało się usunąć hosta", + "credentialsCount": "{{count}} dane uwierzytelniające", + "addCredentialForUser": "Dodaj poświadczenia", + "noCredentialsForUser": "Ten użytkownik nie ma żadnych danych uwierzytelniających", + "deleteCredentialConfirm": "Usunąć poświadczenie „{{name}}” należące do {{username}}?", + "credentialDeletedSuccess": "Usunięto poświadczenie", + "credentialDeleteFailed": "Nie udało się usunąć poświadczeń", + "snippetsCount": "{{count}} fragmenty", + "addSnippetForUser": "Dodaj fragment kodu", + "noSnippetsForUser": "Ten użytkownik nie ma żadnych fragmentów", + "snippetRequiredFields": "Nazwa i treść fragmentu kodu są wymagane", + "snippetNamePlaceholder": "Nazwa fragmentu kodu", + "snippetContentPlaceholder": "Treść polecenia", + "snippetFolderPlaceholder": "Folder (opcjonalnie)", + "snippetSaved": "Fragment zapisany", + "snippetSaveFailed": "Nie udało się zapisać fragmentu kodu", + "deleteSnippetConfirm": "Usunąć fragment kodu „{{name}}” należący do {{username}}?", + "snippetDeletedSuccess": "Usunięto fragment", + "snippetDeleteFailed": "Nie udało się usunąć fragmentu kodu", + "noSessionsForUser": "Brak aktywnych sesji", + "deleteUserDangerDesc": "Trwale usuń {{username}} i wszystkie jego dane (hosty, dane uwierzytelniające, fragmenty kodu, historię). Tej operacji nie można cofnąć.", + "deleteUserConfirm": "Czy trwale usunąć {{username}} i wszystkie jego dane?", + "deleteUserAdminBlocked": "Przed usunięciem tego użytkownika usuń status administratora.", "createRoleRequired": "Wymagane jest podanie imienia i nazwiska oraz nazwy wyświetlanej", "createRoleSuccess": "Utworzono rolę „{{name}}”", "createRoleFailed": "Nie udało się utworzyć roli", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Historia dowodzenia", "hostDefaultsCommandHistoryDesc": "Domyślnie śledź historię poleceń na nowych hostach", "hostDefaultsSaved": "Zapisano ustawienia domyślne hosta", - "hostDefaultsSaveFailed": "Nie udało się zapisać domyślnych ustawień hosta" + "hostDefaultsSaveFailed": "Nie udało się zapisać domyślnych ustawień hosta", + "rolePermissions": { + "count": "{{count}} uprawnienia", + "editAction": "Edytuj uprawnienia", + "loadError": "Nie udało się załadować katalogu uprawnień", + "saved": "Zapisano uprawnienia roli", + "saveError": "Nie udało się zapisać uprawnień roli", + "save": "Ratować", + "saving": "Oszczędność..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Przegrany" }, "userProfile": { + "donateTitle": "Utrzymajmy Termix przy życiu", + "donateDescription": "Termix jest darmowy, hostowany samodzielnie i tworzony przez zaledwie kilka osób w wolnym czasie. Jeśli zaoszczędziłeś czas lub pieniądze, darowizna w kryptowalutach pomoże Ci go utrzymać.", + "donateMilestones": "Darowizny pomagają nam sfinansować czas poświęcony na badania i naukę potrzebną do wdrożenia obsługi SAML, Kubernetes i agentów. Zobacz postępy i przekaż darowiznę.", + "donateButton": "Przekaż kryptowalutę", "storageModeLocal": "Przeglądarka", "storageModeCloud": "Baza danych", "storageModeDescription": "Przeglądarka zapisuje ustawienia tylko w tej przeglądarce. Baza danych synchronizuje się z serwerem i ładuje się na dowolnym urządzeniu.", @@ -3011,6 +3144,10 @@ "twoFaOn": "NA", "twoFaOff": "Wyłączony", "versionLabel": "Wersja", + "betaProgramTitle": "Program beta", + "betaProgramDescription": "Wypróbuj nadchodzące funkcje już teraz dzięki cotygodniowemu tagowi :beta Docker. Wersja niestabilna, nie do użytku produkcyjnego.", + "betaProgramFeedback": "Znalazłeś błąd? Zgłoś go tutaj.", + "betaProgramLearnMore": "Dowiedz się więcej", "deleteAccount": "Usuń konto", "deleteAccountDescription": "Trwale usuń swoje konto", "changeServerDescription": "Przełącz się na inny serwer zaplecza Termix", diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index 43b622d9..21652fb7 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Padrões de nomes separados por vírgulas que permitem ao Docker identificar máquinas virtuais.", "proxmoxPreferredRanges": "Faixas de IP preferenciais", "proxmoxPreferredRangesDesc": "Prefixos separados por vírgula em ordem de prioridade para seleção de IP quando um convidado possui múltiplas interfaces.", + "proxmoxAutoSync": "Sincronização automática de convidados", + "proxmoxAutoSyncDesc": "Descubra periodicamente este nó Proxmox e crie ou atualize hosts convidados importados enquanto sua sessão estiver desbloqueada.", + "proxmoxSyncInterval": "Intervalo de sincronização (minutos)", + "proxmoxSyncIntervalDesc": "Tempo mínimo de 5 minutos. O agendador ignora sessões de usuário bloqueadas.", + "proxmoxMarkMissing": "Marque os hóspedes ausentes", + "proxmoxMarkMissingDesc": "Adicione a tag proxmox-missing quando uma máquina virtual previamente importada desaparecer, em vez de excluí-la.", + "proxmoxLastSync": "Última sincronização", + "proxmoxLastSyncNever": "Ainda não sincronizado", + "proxmoxLastSyncNoResult": "Nenhum detalhe do resultado", + "proxmoxLastSyncSummary": "{{created}} criado, {{updated}} atualizado, {{markedMissing}} ausente, {{skipped}} ignorado", + "proxmoxLastSyncStatus": { + "success": "Sucesso", + "error": "Fracassado", + "pending": "Pendente" + }, "proxmoxDiscoverAction": "Descubra e importe hóspedes do Proxmox", "proxmoxImportTitle": "Importar do Proxmox", "proxmoxSelectHost": "Selecione um host Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Compartilhar Host", "shareHostTitle": "Compartilhar: {{name}}", "sharing": { - "requiresCredential": "Este host precisa usar uma credencial para permitir o compartilhamento. Edite o host e atribua uma credencial primeiro." + "loadError": "Não foi possível carregar os dados de compartilhamento. Tente novamente.", + "shareWithSection": "Compartilhe com", + "usersTab": "Usuários", + "rolesTab": "Funções", + "searchPlaceholder": "Pesquisar usuários ou funções...", + "noMatches": "Nenhuma correspondência encontrada", + "permissionLevelLabel": "Nível de permissão", + "levels": { + "connect": { + "label": "Conectar", + "description": "Somente sessões abertas: terminal, área de trabalho remota, gerenciador de arquivos, túneis e Docker. Sem acesso à configuração do host." + }, + "view": { + "label": "Visualizar", + "description": "Conecte-se e veja a configuração do host. Os segredos nunca são exibidos." + }, + "edit": { + "label": "Editar", + "description": "Visualize e modifique o host. Os segredos podem ser substituídos, mas nunca lidos; as atribuições de credenciais permanecem exclusivas do proprietário." + }, + "manage": { + "label": "Gerenciar", + "description": "Edite, compartilhe o host com outras pessoas, altere os níveis de permissão e revogue o acesso." + } + }, + "expiryLabel": "Expiração do acesso", + "expiry": { + "never": "Nunca", + "oneHour": "1 hora", + "oneDay": "24 horas", + "sevenDays": "7 dias", + "thirtyDays": "30 dias", + "custom": "Personalizado" + }, + "customHoursPlaceholder": "Horas restantes até o acesso expirar", + "shareButton": "Compartilhar", + "shareWithCount": "Compartilhar ({{count}})", + "currentAccess": "Acesso atual", + "noAccessEntries": "Este host ainda não foi compartilhado.", + "grantedBy": "Concedido por", + "expires": "Expira", + "expired": "Expirado", + "never": "Nunca", + "revoke": "Revogar", + "accessUpdated": "Acesso atualizado", + "accessUpdateFailed": "Falha ao atualizar o acesso", + "sharedBadge": "Compartilhado", + "sharedBadgeTooltip": "Compartilhado por {{owner}} ({{level}} acesso)", + "viewOnlyBanner": "Este host foi compartilhado com você por {{owner}} com acesso de visualização. A configuração é somente leitura.", + "sharedEditBanner": "Este host foi compartilhado com você por {{owner}} com acesso de edição. As alterações se aplicam ao host real; as referências de autenticação só podem ser alteradas pelo proprietário.", + "ownerOnlyControl": "Somente o proprietário do host pode alterar isso." }, "guac": { "connection": "Conexão", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tecla Backspace", "saveHostFirst": "Primeiro, salve o host.", "sharingOptionsAfterSave": "As opções de compartilhamento ficam disponíveis depois que o anfitrião for salvo.", - "sharingLoadError": "Não foi possível carregar os dados de compartilhamento. Verifique sua conexão e tente novamente.", - "shareHostSection": "Compartilhar Host", - "shareWithUser": "Compartilhar com o usuário", - "shareWithRole": "Compartilhar com a função", - "selectUser": "Selecionar usuário", - "selectRole": "Selecione a função", - "selectUserOption": "Selecione um usuário...", - "selectRoleOption": "Selecione uma função...", "permissionLevel": "Nível de permissão", - "expiresInHours": "Expira em (horas)", - "noExpiryPlaceholder": "Deixe em branco para não expirar.", - "shareBtn": "Compartilhar", - "currentAccess": "Acesso atual", "typeHeader": "Tipo", "targetHeader": "Alvo", "permissionHeader": "Permissão", - "grantedByHeader": "Concedido por", - "expiresHeader": "Expira", - "noAccessEntries": "Ainda não há entradas de acesso.", - "expiredLabel": "Expirado", - "neverLabel": "Nunca", - "revokeBtn": "Revogar", "cancelBtn": "Cancelar", "savingBtn": "Salvando...", "updateHostBtn": "Atualizar Host", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "O login com senha está desativado. Use uma chave de acesso ou um provedor de autenticação externo.", "signInWithPasskey": "Faça login com a chave de acesso.", "passkeyLoginFailed": "Falha no login com a chave de acesso", - "attemptsRemaining": "{{count}} tentativas restantes" + "attemptsRemaining": "{{count}} tentativas restantes", + "confirmResetDataWipe": "Esta conta não foi acessada desde a atualização da criptografia, portanto, seus dados armazenados não podem ser recuperados sem a senha antiga. A redefinição excluirá permanentemente seus hosts, credenciais e trechos de código. Continuar?" }, "hostKey": { "verifyNewHost": "Verificar chave de host SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Links de serviço", "homepagePreview": "Pré-visualização da página inicial" }, + "donation": { + "title": "Gostando do Termix?", + "body": "O Termix é gratuito e de código aberto, desenvolvido e mantido por uma equipe de duas pessoas em nosso tempo livre. Se ele substituir uma ferramenta comercial pela qual você pagaria, uma doação ajuda a cobrir os custos de hospedagem e a manter o desenvolvimento em andamento. No momento, as doações são aceitas somente em criptomoedas.", + "milestones": "As doações também ajudam a financiar o tempo necessário para pesquisar e aprender o que é preciso para desenvolver recursos como suporte a SAML e Kubernetes. Veja o progresso na página de doações.", + "cta": "Doar", + "dismiss": "Talvez mais tarde" + }, "dashboardTab": { "stable": "ESTÁVEL", "hostsOnline": "Anfitriões Online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Falha ao atualizar o status de administrador", "allSessionsRevoked": "Todas as sessões foram revogadas.", "revokeSessionsFailed": "Falha ao revogar sessões", + "manageUserData": "Gerenciar dados do usuário", + "backToUsers": "Voltar aos usuários", + "manageTabAccount": "Conta", + "manageTabHosts": "Anfitriões", + "manageTabCredentials": "Credenciais", + "manageTabSnippets": "Trechos", + "manageTabSessions": "Sessões", + "manageTabDanger": "Perigo", + "manageEditorBack": "Voltar aos dados de {{username}}", + "dataLockedBadge": "TRANCADO", + "dataLockedNotice": "Os dados de {{username}}permanecem bloqueados até o próximo login. Seus hosts, credenciais e trechos de código não podem ser visualizados ou editados até então.", + "resetPasswordTitle": "Redefinir senha", + "resetPasswordOidcOnly": "Este usuário faz login por meio de um provedor externo e não possui senha.", + "resetPasswordPlaceholder": "Nova Senha", + "resetPasswordBtn": "Reiniciar", + "resetPasswordWorking": "Reiniciando...", + "resetPasswordSuccess": "Redefinição de senha", + "resetPasswordSuccessWiped": "Redefinição de senha. Os dados criptografados do usuário foram apagados.", + "resetPasswordFailed": "Falha ao redefinir a senha", + "resetPasswordConfirmWipe": "{{username}} não fez login desde a atualização de criptografia, portanto, seus dados não podem ser recuperados. Redefinir agora excluirá seus hosts, credenciais e trechos de código. Continuar?", + "totpSectionTitle": "Autenticação de dois fatores", + "totpStatusEnabled": "O TOTP está ativado para este usuário.", + "totpStatusDisabled": "O TOTP não está habilitado para este usuário.", + "disableTotp": "Desativar", + "disableTotpConfirm": "Desativar autenticação de dois fatores para {{username}}? Eles poderão fazer login apenas com a senha.", + "totpDisabledSuccess": "TOTP desativado", + "totpDisableFailed": "Falha ao desativar o TOTP", + "manageApiKeys": "Chaves de API", + "apiKeyNamePlaceholder": "Nome da chave", + "apiKeyCopyNotice": "Copie esta chave agora, ela não será exibida novamente.", + "noApiKeysForUser": "Sem chaves de API", + "apiKeyDeleteFailed": "Falha ao excluir a chave da API", + "exportUserData": "Exportação de dados", + "exportUserDataDesc": "Baixe os hosts, credenciais e dados do gerenciador de arquivos deste usuário em formato JSON. Os segredos estão descriptografados.", + "exportUserDataSuccess": "Dados do usuário exportados", + "exportUserDataFailed": "Falha ao exportar dados do usuário", + "hostsCount": "{{count}} anfitriões", + "addHostForUser": "Adicionar host", + "noHostsForUser": "Este usuário não possui hosts.", + "connectToHost": "Conectar", + "deleteHostConfirm": "Excluir host \"{{name}}\" pertencente a {{username}}?", + "hostDeletedSuccess": "Host excluído", + "hostDeleteFailed": "Falha ao excluir o host", + "credentialsCount": "{{count}} credenciais", + "addCredentialForUser": "Adicionar credencial", + "noCredentialsForUser": "Este usuário não possui credenciais.", + "deleteCredentialConfirm": "Excluir credencial \"{{name}}\" pertencente a {{username}}?", + "credentialDeletedSuccess": "Credencial excluída", + "credentialDeleteFailed": "Falha ao excluir credencial", + "snippetsCount": "{{count}} trechos", + "addSnippetForUser": "Adicionar trecho de código", + "noSnippetsForUser": "Este usuário não possui trechos de código.", + "snippetRequiredFields": "O nome e o conteúdo do snippet são obrigatórios.", + "snippetNamePlaceholder": "Nome do trecho", + "snippetContentPlaceholder": "Conteúdo do comando", + "snippetFolderPlaceholder": "Pasta (opcional)", + "snippetSaved": "Trecho salvo", + "snippetSaveFailed": "Falha ao salvar o trecho de código", + "deleteSnippetConfirm": "Excluir trecho \"{{name}}\" pertencente a {{username}}?", + "snippetDeletedSuccess": "Trecho excluído", + "snippetDeleteFailed": "Falha ao excluir o trecho", + "noSessionsForUser": "Nenhuma sessão ativa", + "deleteUserDangerDesc": "Exclua permanentemente {{username}} e todos os seus dados (hosts, credenciais, trechos de código, histórico). Esta ação é irreversível.", + "deleteUserConfirm": "Apagar permanentemente {{username}} e todos os seus dados?", + "deleteUserAdminBlocked": "Remova o status de administrador antes de excluir este usuário.", "createRoleRequired": "Nome e nome de exibição são obrigatórios.", "createRoleSuccess": "Função \"{{name}}\" criada", "createRoleFailed": "Falha ao criar a função", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Histórico de comandos", "hostDefaultsCommandHistoryDesc": "Por padrão, rastreie o histórico de comandos em novos hosts.", "hostDefaultsSaved": "Configurações padrão do host salvas", - "hostDefaultsSaveFailed": "Falha ao salvar as configurações padrão do host" + "hostDefaultsSaveFailed": "Falha ao salvar as configurações padrão do host", + "rolePermissions": { + "count": "{{count}} permissões", + "editAction": "Editar permissões", + "loadError": "Falha ao carregar o catálogo de permissões", + "saved": "Permissões de função salvas", + "saveError": "Falha ao salvar as permissões de função.", + "save": "Salvar", + "saving": "Salvando..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Fracassado" }, "userProfile": { + "donateTitle": "Mantenha o Termix vivo", + "donateDescription": "O Termix é gratuito, auto-hospedado e desenvolvido por apenas algumas pessoas em seu tempo livre. Se ele lhe economizou tempo ou dinheiro, uma doação em criptomoedas ajuda a mantê-lo funcionando.", + "donateMilestones": "As doações ajudam a financiar o tempo necessário para pesquisar e aprender o que é preciso para desenvolver suporte para SAML, Kubernetes e Agentes. Veja o progresso e doe.", + "donateButton": "Doe criptomoedas", "storageModeLocal": "Navegador", "storageModeCloud": "Banco de dados", "storageModeDescription": "O navegador armazena as configurações apenas neste navegador. O banco de dados é sincronizado com o servidor e carregado em qualquer dispositivo.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Sobre", "twoFaOff": "Desligado", "versionLabel": "Versão", + "betaProgramTitle": "Programa Beta", + "betaProgramDescription": "Experimente antecipadamente os próximos recursos com a tag Docker :beta, distribuída semanalmente. Instável, não recomendada para produção.", + "betaProgramFeedback": "Encontrou um erro? Reporte-o aqui.", + "betaProgramLearnMore": "Saber mais", "deleteAccount": "Excluir conta", "deleteAccountDescription": "Excluir sua conta permanentemente", "changeServerDescription": "Mude para um servidor backend Termix diferente.", diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index 87d8003b..e10abf75 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Padrões de nomes separados por vírgulas que permitem ao Docker identificar máquinas virtuais.", "proxmoxPreferredRanges": "Faixas de IP preferenciais", "proxmoxPreferredRangesDesc": "Prefixos separados por vírgula por ordem de prioridade para a seleção de IP quando um convidado tem várias interfaces.", + "proxmoxAutoSync": "Sincronização automática de convidados", + "proxmoxAutoSyncDesc": "Descubra periodicamente este nó Proxmox e crie ou atualize hosts convidados importados enquanto a sua sessão estiver desbloqueada.", + "proxmoxSyncInterval": "Intervalo de sincronização (minutos)", + "proxmoxSyncIntervalDesc": "Tempo mínimo de 5 minutos. O agendador ignora as sessões de utilizador bloqueadas.", + "proxmoxMarkMissing": "Marque os hóspedes ausentes", + "proxmoxMarkMissingDesc": "Adicione a etiqueta proxmox-missing quando uma máquina virtual previamente importada desaparecer, em vez de a eliminar.", + "proxmoxLastSync": "Última sincronização", + "proxmoxLastSyncNever": "Ainda não sincronizado", + "proxmoxLastSyncNoResult": "Sem detalhe do resultado", + "proxmoxLastSyncSummary": "{{created}} criado, {{updated}} atualizado, {{markedMissing}} ausente, {{skipped}} ignorado", + "proxmoxLastSyncStatus": { + "success": "Sucesso", + "error": "Falhou", + "pending": "Pendente" + }, "proxmoxDiscoverAction": "Descubra e importe hóspedes do Proxmox", "proxmoxImportTitle": "Importar do Proxmox", "proxmoxSelectHost": "Selecione um host Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Partilhar Host", "shareHostTitle": "Partilhar: {{name}}", "sharing": { - "requiresCredential": "Este host necessita de usar uma credencial para permitir a partilha. Edite o host e atribua primeiro uma credencial." + "loadError": "Não foi possível carregar os dados de partilha. Tente novamente.", + "shareWithSection": "Partilhe com", + "usersTab": "Usuários", + "rolesTab": "Funções", + "searchPlaceholder": "Pesquisar utilizadores ou funções...", + "noMatches": "Nenhuma correspondência encontrada", + "permissionLevelLabel": "Nível de permissão", + "levels": { + "connect": { + "label": "Ligar", + "description": "Apenas sessões abertas: terminal, ambiente de trabalho remoto, gestor de ficheiros, túneis e Docker. Sem acesso à configuração do host." + }, + "view": { + "label": "Ver", + "description": "Ligue-se e veja a configuração do host. Os segredos nunca são exibidos." + }, + "edit": { + "label": "Editar", + "description": "Visualize e modifique o host. Os segredos podem ser substituídos, mas nunca lidos; as atribuições de credenciais permanecem exclusivas do proprietário." + }, + "manage": { + "label": "Gerir", + "description": "Edite, partilhe o host com outras pessoas, altere os níveis de permissão e revogue o acesso." + } + }, + "expiryLabel": "Expiração do acesso", + "expiry": { + "never": "Nunca", + "oneHour": "1 hora", + "oneDay": "24 horas", + "sevenDays": "7 dias", + "thirtyDays": "30 dias", + "custom": "Personalizado" + }, + "customHoursPlaceholder": "Horas restantes até o acesso expirar", + "shareButton": "Partilhar", + "shareWithCount": "Partilhar ({{count}})", + "currentAccess": "Acesso atual", + "noAccessEntries": "Este host ainda não foi partilhado.", + "grantedBy": "Concedido por", + "expires": "Expira", + "expired": "Expirado", + "never": "Nunca", + "revoke": "Revogar", + "accessUpdated": "Acesso atualizado", + "accessUpdateFailed": "Falha ao atualizar o acesso", + "sharedBadge": "Compartilhado", + "sharedBadgeTooltip": "Partilhado por {{owner}} ({{level}} acesso)", + "viewOnlyBanner": "Este host foi partilhado consigo por {{owner}} com acesso de visualização. A configuração é de leitura apenas.", + "sharedEditBanner": "Este host foi partilhado consigo por {{owner}} com acesso de edição. As alterações aplicam-se ao host real; as referências de autenticação só podem ser alteradas pelo proprietário.", + "ownerOnlyControl": "Apenas o proprietário do host pode alterar isso." }, "guac": { "connection": "Conexão", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tecla Backspace", "saveHostFirst": "Primeiro, guarde o host.", "sharingOptionsAfterSave": "As opções de partilha ficam disponíveis depois de o anfitrião ser guardado.", - "sharingLoadError": "Não foi possível carregar os dados de partilha. Verifique a sua ligação e tente novamente.", - "shareHostSection": "Partilhar Host", - "shareWithUser": "Partilhar com o usuário", - "shareWithRole": "Partilhar com a função", - "selectUser": "Selecionar utilizador", - "selectRole": "Selecione a função", - "selectUserOption": "Selecione um utilizador...", - "selectRoleOption": "Selecione uma função...", "permissionLevel": "Nível de permissão", - "expiresInHours": "Expira em (horas)", - "noExpiryPlaceholder": "Deixe em branco para não expirar.", - "shareBtn": "Partilhar", - "currentAccess": "Acesso atual", "typeHeader": "Tipo", "targetHeader": "Objetivo", "permissionHeader": "Permissão", - "grantedByHeader": "Concedido por", - "expiresHeader": "Expira", - "noAccessEntries": "Ainda não existem entradas de acesso.", - "expiredLabel": "Expirado", - "neverLabel": "Nunca", - "revokeBtn": "Revogar", "cancelBtn": "Cancelar", "savingBtn": "Salvando...", "updateHostBtn": "Atualizar Host", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "O login com palavra-passe está desativado. Utilize uma chave de acesso ou um fornecedor de autenticação externo.", "signInWithPasskey": "Faça login com a chave de acesso.", "passkeyLoginFailed": "Falha no login com a chave de acesso", - "attemptsRemaining": "{{count}} restantes tentativas" + "attemptsRemaining": "{{count}} restantes tentativas", + "confirmResetDataWipe": "Esta conta não foi acedida desde a atualização da encriptação, pelo que os seus dados armazenados não podem ser recuperados sem a palavra-passe antiga. A reposição eliminará permanentemente os seus hosts, credenciais e trechos de código. Continuar?" }, "hostKey": { "verifyNewHost": "Verificar chave de host SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Links de serviço", "homepagePreview": "Pré-visualização da página inicial" }, + "donation": { + "title": "Gosta do Termix?", + "body": "O Termix é gratuito e de código aberto, desenvolvido e mantido por uma equipa de duas pessoas no nosso tempo livre. Se substituir uma ferramenta comercial pela qual pagaria, um donativo ajuda a cobrir os custos de alojamento e a manter o desenvolvimento em curso. Neste momento, as doações são aceites apenas em criptomoedas.", + "milestones": "Os donativos também ajudam a financiar o tempo necessário para pesquisar e aprender o que é necessário para desenvolver recursos como o apoio ao SAML e ao Kubernetes. Veja o progresso na página de donativos.", + "cta": "Doar", + "dismiss": "Talvez mais tarde" + }, "dashboardTab": { "stable": "ESTÁVEL", "hostsOnline": "Anfitriões Online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Falha ao atualizar o estado de administrador", "allSessionsRevoked": "Todas as sessões foram revogadas.", "revokeSessionsFailed": "Falha ao revogar sessões", + "manageUserData": "Gerir dados do utilizador", + "backToUsers": "Voltar aos utilizadores", + "manageTabAccount": "Conta", + "manageTabHosts": "Anfitriões", + "manageTabCredentials": "Credenciais", + "manageTabSnippets": "Trechos", + "manageTabSessions": "Sessões", + "manageTabDanger": "Perigo", + "manageEditorBack": "Voltar aos dados de {{username}}", + "dataLockedBadge": "TRANCADO", + "dataLockedNotice": "Os dados de {{username}}permanecem bloqueados até ao próximo login. Os seus hosts, credenciais e pedaços de código não podem ser visualizados ou editados até então.", + "resetPasswordTitle": "Redefinir palavra-passe", + "resetPasswordOidcOnly": "Este utilizador faz login através de um fornecedor externo e não possui palavra-passe.", + "resetPasswordPlaceholder": "Nova Senha", + "resetPasswordBtn": "Reiniciar", + "resetPasswordWorking": "Reiniciando...", + "resetPasswordSuccess": "Redefinição de palavra-passe", + "resetPasswordSuccessWiped": "Redefinição de palavra-passe. Os dados encriptados do utilizador foram apagados.", + "resetPasswordFailed": "Falha ao redefinir a palavra-passe", + "resetPasswordConfirmWipe": "{{username}} não fez login desde a atualização de encriptação, pelo que os seus dados não podem ser recuperados. O reset irá agora excluir os seus hosts, credenciais e trechos de código. Continuar?", + "totpSectionTitle": "Autenticação de dois fatores", + "totpStatusEnabled": "O TOTP está ativado para este utilizador.", + "totpStatusDisabled": "O TOTP não está ativado para este utilizador.", + "disableTotp": "Desativar", + "disableTotpConfirm": "Desativar a autenticação de dois fatores para {{username}}? Poderão fazer login apenas com a palavra-passe.", + "totpDisabledSuccess": "TOTP desativado", + "totpDisableFailed": "Falha ao desativar o TOTP", + "manageApiKeys": "Chaves API", + "apiKeyNamePlaceholder": "Nome da chave", + "apiKeyCopyNotice": "Copie esta chave agora, não voltará a ser apresentada.", + "noApiKeysForUser": "Sem chaves API", + "apiKeyDeleteFailed": "Falha ao eliminar a chave API", + "exportUserData": "Exportação de dados", + "exportUserDataDesc": "Transfira os hosts, as credenciais e os dados do gestor de ficheiros deste utilizador em formato JSON. Os segredos estão desencriptados.", + "exportUserDataSuccess": "Dados do utilizador exportados", + "exportUserDataFailed": "Falha ao exportar dados do utilizador", + "hostsCount": "{{count}} anfitriões", + "addHostForUser": "Adicionar host", + "noHostsForUser": "Este utilizador não possui hosts.", + "connectToHost": "Ligar", + "deleteHostConfirm": "Apagar host \"{{name}}\" pertencente a {{username}}?", + "hostDeletedSuccess": "Host excluído", + "hostDeleteFailed": "Falha ao eliminar o host", + "credentialsCount": "{{count}} credenciais", + "addCredentialForUser": "Adicionar credencial", + "noCredentialsForUser": "Este utilizador não possui credenciais.", + "deleteCredentialConfirm": "Apagar credencial \"{{name}}\" pertencente a {{username}}?", + "credentialDeletedSuccess": "Credencial excluída", + "credentialDeleteFailed": "Falha ao eliminar credencial", + "snippetsCount": "{{count}} excertos", + "addSnippetForUser": "Adicionar trecho de código", + "noSnippetsForUser": "Este utilizador não possui trechos de código.", + "snippetRequiredFields": "O nome e o conteúdo do excerto são obrigatórios.", + "snippetNamePlaceholder": "Nome do excerto", + "snippetContentPlaceholder": "Conteúdo do comando", + "snippetFolderPlaceholder": "Pasta (opcional)", + "snippetSaved": "Trecho salvo", + "snippetSaveFailed": "Falha ao guardar o trecho de código", + "deleteSnippetConfirm": "Apagar excerto \"{{name}}\" pertencente a {{username}}?", + "snippetDeletedSuccess": "Trecho excluído", + "snippetDeleteFailed": "Falha ao eliminar o trecho", + "noSessionsForUser": "Nenhuma sessão ativa", + "deleteUserDangerDesc": "Apague permanentemente {{username}} e todos os seus dados (hosts, credenciais, excertos de código, histórico). Esta ação é irreversível.", + "deleteUserConfirm": "Apagar permanentemente {{username}} e todos os seus dados?", + "deleteUserAdminBlocked": "Remova o estado de administrador antes de eliminar este utilizador.", "createRoleRequired": "O nome e o nome de exibição são obrigatórios.", "createRoleSuccess": "Função \"{{name}}\" criada", "createRoleFailed": "Falha ao criar a função", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Histórico de comandos", "hostDefaultsCommandHistoryDesc": "Por predefinição, acompanhe o histórico de comandos em novos hosts.", "hostDefaultsSaved": "Configurações padrão do host guardadas", - "hostDefaultsSaveFailed": "Falha ao guardar as definições padrão do host" + "hostDefaultsSaveFailed": "Falha ao guardar as definições padrão do host", + "rolePermissions": { + "count": "{{count}} permissões", + "editAction": "Editar permissões", + "loadError": "Falha ao carregar o catálogo de permissões", + "saved": "Permissões de função guardadas", + "saveError": "Falha ao guardar as permissões de função.", + "save": "Guardar", + "saving": "Salvando..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Falhou" }, "userProfile": { + "donateTitle": "Mantenha o Termix vivo", + "donateDescription": "O Termix é gratuito, auto-hospedado e desenvolvido por apenas algumas pessoas no seu tempo livre. Se ele lhe poupou tempo ou dinheiro, uma doação em criptomoedas ajuda a mantê-lo em funcionamento.", + "donateMilestones": "Os donativos ajudam a financiar o tempo necessário para pesquisar e aprender o que é necessário para desenvolver o apoio para SAML, Kubernetes e Agentes. Veja o progresso e doe.", + "donateButton": "Doe criptomoedas", "storageModeLocal": "Navegador", "storageModeCloud": "Banco de dados", "storageModeDescription": "O navegador armazena as definições apenas neste navegador. A base de dados é sincronizada com o servidor e carregada em qualquer dispositivo.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Em", "twoFaOff": "Desligado", "versionLabel": "Versão", + "betaProgramTitle": "Programa Beta", + "betaProgramDescription": "Experimente antecipadamente os próximos recursos com a etiqueta Docker :beta, distribuída semanalmente. Instável, não recomendada para produção.", + "betaProgramFeedback": "Encontrou um erro? Reporte-o aqui.", + "betaProgramLearnMore": "Saber mais", "deleteAccount": "Apagar conta", "deleteAccountDescription": "Apagar a sua conta permanentemente", "changeServerDescription": "Mude para um servidor de backend Termix diferente.", diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index fdaf2d7d..319c87b5 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Modele de nume separate prin virgulă care activează Docker pentru invitații potriviți", "proxmoxPreferredRanges": "Intervalele IP preferate", "proxmoxPreferredRangesDesc": "Prefixe separate prin virgulă în ordinea priorității pentru selectarea adresei IP atunci când un oaspete are mai multe interfețe", + "proxmoxAutoSync": "Sincronizare automată a invitaților", + "proxmoxAutoSyncDesc": "Descoperiți periodic acest nod Proxmox și creați sau actualizați gazdele invitate importate în timp ce sesiunea este deblocată.", + "proxmoxSyncInterval": "Interval de sincronizare (minute)", + "proxmoxSyncIntervalDesc": "Minim 5 minute. Planificatorul omite sesiunile utilizatorilor blocate.", + "proxmoxMarkMissing": "Marchează oaspeții lipsă", + "proxmoxMarkMissingDesc": "Adăugați o etichetă proxmox-missing atunci când un invitat importat anterior dispare, în loc să îl ștergeți.", + "proxmoxLastSync": "Ultima sincronizare", + "proxmoxLastSyncNever": "Încă nesincronizat", + "proxmoxLastSyncNoResult": "Niciun detaliu al rezultatului", + "proxmoxLastSyncSummary": "{{created}} creat, {{updated}} actualizat, {{markedMissing}} lipsă, {{skipped}} omis", + "proxmoxLastSyncStatus": { + "success": "Succes", + "error": "Eșuat", + "pending": "În așteptare" + }, "proxmoxDiscoverAction": "Descoperiți și importați invitați Proxmox", "proxmoxImportTitle": "Import din Proxmox", "proxmoxSelectHost": "Selectați o gazdă Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Gazdă partajată", "shareHostTitle": "Distribuie: {{name}}", "sharing": { - "requiresCredential": "Această gazdă trebuie să utilizeze o acreditare pentru a activa partajarea. Editați gazda și atribuiți mai întâi o acreditare." + "loadError": "Nu s-au putut încărca datele de partajare. Vă rugăm să încercați din nou.", + "shareWithSection": "Distribuiți cu", + "usersTab": "Utilizatori", + "rolesTab": "Roluri", + "searchPlaceholder": "Căutați utilizatori sau roluri...", + "noMatches": "Nu s-au găsit potriviri", + "permissionLevelLabel": "Nivel de permisiune", + "levels": { + "connect": { + "label": "Conectează-te", + "description": "Deschideți doar sesiunile: terminal, desktop la distanță, manager de fișiere, tuneluri și Docker. Fără acces la configurația gazdei." + }, + "view": { + "label": "Vedere", + "description": "Conectează-te și vezi configurația gazdei. Secretele nu sunt afișate niciodată." + }, + "edit": { + "label": "Edita", + "description": "Vizualizați și modificați gazda. Secretele pot fi înlocuite, dar niciodată citite; atribuirea acreditărilor rămâne doar proprietarului." + }, + "manage": { + "label": "Gestionează", + "description": "Editați, plus partajați gazda cu alții, modificați nivelurile de permisiune și revocați accesul." + } + }, + "expiryLabel": "Expirarea accesului", + "expiry": { + "never": "Nu", + "oneHour": "1 oră", + "oneDay": "24 de ore", + "sevenDays": "7 zile", + "thirtyDays": "30 de zile", + "custom": "Personalizat" + }, + "customHoursPlaceholder": "Ore până la expirarea accesului", + "shareButton": "Distribuie", + "shareWithCount": "Distribuie ({{count}})", + "currentAccess": "Acces curent", + "noAccessEntries": "Această gazdă nu a fost încă partajată", + "grantedBy": "Acordat de", + "expires": "Expiră", + "expired": "Expirat", + "never": "Nu", + "revoke": "Revoca", + "accessUpdated": "Acces actualizat", + "accessUpdateFailed": "Nu s-a putut actualiza accesul", + "sharedBadge": "Partajat", + "sharedBadgeTooltip": "Distribuit de {{owner}} ( acces{{level}})", + "viewOnlyBanner": "Această gazdă este partajată cu dvs. de către {{owner}} cu acces de vizualizare. Configurația este doar pentru citire.", + "sharedEditBanner": "Această gazdă este partajată cu dvs. de către {{owner}} cu acces de editare. Modificările se aplică gazdei reale; referințele de autentificare pot fi modificate numai de către proprietar.", + "ownerOnlyControl": "Doar proprietarul gazdei poate modifica acest lucru" }, "guac": { "connection": "Conexiune", @@ -1302,28 +1367,10 @@ "backspaceKey": "Tasta Backspace", "saveHostFirst": "Salvează mai întâi gazda.", "sharingOptionsAfterSave": "Opțiunile de partajare sunt disponibile după ce gazda a fost salvată.", - "sharingLoadError": "Nu s-au putut încărca datele de partajare. Verificați conexiunea și încercați din nou.", - "shareHostSection": "Gazdă partajată", - "shareWithUser": "Partajați cu utilizatorul", - "shareWithRole": "Partajare cu Rol", - "selectUser": "Selectați utilizatorul", - "selectRole": "Selectați rolul", - "selectUserOption": "Selectați un utilizator...", - "selectRoleOption": "Selectați un rol...", "permissionLevel": "Nivel de permisiune", - "expiresInHours": "Expiră în (ore)", - "noExpiryPlaceholder": "Se lasă gol dacă nu există expirare", - "shareBtn": "Distribuie", - "currentAccess": "Acces curent", "typeHeader": "Tip", "targetHeader": "Ţintă", "permissionHeader": "Permisiune", - "grantedByHeader": "Acordat de", - "expiresHeader": "Expiră", - "noAccessEntries": "Încă nu există intrări de acces.", - "expiredLabel": "Expirat", - "neverLabel": "Nu", - "revokeBtn": "Revoca", "cancelBtn": "Anula", "savingBtn": "Economisire...", "updateHostBtn": "Actualizați gazda", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Conectarea cu parolă este dezactivată. Folosiți o cheie de acces sau un furnizor de autentificare extern.", "signInWithPasskey": "Conectați-vă cu cheia de acces", "passkeyLoginFailed": "Conectarea cu parola a eșuat", - "attemptsRemaining": "{{count}} încercări rămase" + "attemptsRemaining": "{{count}} încercări rămase", + "confirmResetDataWipe": "Acest cont nu s-a mai conectat de la actualizarea criptării, așadar datele sale stocate nu pot fi recuperate fără vechea parolă. Resetarea va șterge definitiv gazdele, acreditările și fragmentele de date. Continuați?" }, "hostKey": { "verifyNewHost": "Verificați cheia gazdă SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Linkuri de servicii", "homepagePreview": "Previzualizare pagină principală" }, + "donation": { + "title": "Îți place Termix?", + "body": "Termix este gratuit și open source, construit și întreținut de o echipă de două persoane în timpul liber. Dacă înlocuiește un instrument comercial pentru care altfel ați plăti, o donație ajută la acoperirea costurilor de găzduire și menține dezvoltarea în desfășurare. În prezent, donațiile sunt doar criptomonede.", + "milestones": "Donațiile ajută, de asemenea, la finanțarea timpului necesar pentru cercetare și învățare a ceea ce este necesar pentru a construi funcții precum suportul SAML și Kubernetes. Vedeți progresul pe pagina de donații.", + "cta": "Dona", + "dismiss": "Poate mai târziu" + }, "dashboardTab": { "stable": "STABIL", "hostsOnline": "Gazde online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Nu s-a putut actualiza starea de administrator", "allSessionsRevoked": "Toate sesiunile revocate", "revokeSessionsFailed": "Nu s-au putut revoca sesiunile", + "manageUserData": "Gestionați datele utilizatorilor", + "backToUsers": "Înapoi la utilizatori", + "manageTabAccount": "Cont", + "manageTabHosts": "Gazde", + "manageTabCredentials": "Acreditări", + "manageTabSnippets": "Fragmente", + "manageTabSessions": "Sesiuni", + "manageTabDanger": "Pericol", + "manageEditorBack": "Înapoi la datele lui {{username}}", + "dataLockedBadge": "BLOCAT", + "dataLockedNotice": "Datele utilizatorului {{username}}rămân blocate până la următoarea conectare. Gazdele, acreditările și fragmentele de date nu pot fi vizualizate sau editate până atunci.", + "resetPasswordTitle": "Resetare parolă", + "resetPasswordOidcOnly": "Acest utilizator se conectează printr-un furnizor extern și nu are parolă.", + "resetPasswordPlaceholder": "Parolă Nouă", + "resetPasswordBtn": "Resetare", + "resetPasswordWorking": "Resetare...", + "resetPasswordSuccess": "Resetare parolă", + "resetPasswordSuccessWiped": "Resetare parolă. Datele criptate ale utilizatorului au fost șterse.", + "resetPasswordFailed": "Resetarea parolei a eșuat", + "resetPasswordConfirmWipe": "{{username}} nu s-a conectat de la actualizarea criptării, prin urmare datele sale nu pot fi recuperate. Resetarea acum va șterge gazdele, acreditările și fragmentele de date. Continuați?", + "totpSectionTitle": "Autentificare cu doi factori", + "totpStatusEnabled": "TOTP este activat pentru acest utilizator", + "totpStatusDisabled": "TOTP nu este activat pentru acest utilizator", + "disableTotp": "Dezactivare", + "disableTotpConfirm": "Dezactivați autentificarea cu doi factori pentru {{username}}? Aceștia se vor putea conecta doar cu parola.", + "totpDisabledSuccess": "TOTP dezactivat", + "totpDisableFailed": "Nu s-a putut dezactiva TOTP", + "manageApiKeys": "Chei API", + "apiKeyNamePlaceholder": "Numele cheii", + "apiKeyCopyNotice": "Copiază această cheie acum, nu va mai fi afișată.", + "noApiKeysForUser": "Fără chei API", + "apiKeyDeleteFailed": "Cheia API nu a putut fi ștersă.", + "exportUserData": "Exportul datelor", + "exportUserDataDesc": "Descarcă gazdele, acreditările și datele managerului de fișiere ale acestui utilizator ca JSON. Secretele sunt decriptate.", + "exportUserDataSuccess": "Datele utilizatorului exportate", + "exportUserDataFailed": "Exportul datelor utilizatorului a eșuat", + "hostsCount": "gazde {{count}}", + "addHostForUser": "Adăugați gazdă", + "noHostsForUser": "Acest utilizator nu are gazde", + "connectToHost": "Conectează-te", + "deleteHostConfirm": "Ștergeți gazda „{{name}}” aparținând lui {{username}}?", + "hostDeletedSuccess": "Gazdă ștearsă", + "hostDeleteFailed": "Nu s-a putut șterge gazda", + "credentialsCount": "acreditări {{count}}", + "addCredentialForUser": "Adăugați acreditări", + "noCredentialsForUser": "Acest utilizator nu are acreditări", + "deleteCredentialConfirm": "Ștergeți acreditările „{{name}}” aparținând lui {{username}}?", + "credentialDeletedSuccess": "Acreditare ștearsă", + "credentialDeleteFailed": "Ștergerea acreditării nu a reușit", + "snippetsCount": "fragmente {{count}}", + "addSnippetForUser": "Adăugați fragment", + "noSnippetsForUser": "Acest utilizator nu are fragmente", + "snippetRequiredFields": "Numele și conținutul fragmentului sunt obligatorii", + "snippetNamePlaceholder": "Nume fragment", + "snippetContentPlaceholder": "Conținutul comenzii", + "snippetFolderPlaceholder": "Dosar (opțional)", + "snippetSaved": "Fragment salvat", + "snippetSaveFailed": "Nu s-a putut salva fragmentul", + "deleteSnippetConfirm": "Ștergeți fragmentul „{{name}}” aparținând lui {{username}}?", + "snippetDeletedSuccess": "Fragment șters", + "snippetDeleteFailed": "Nu s-a putut șterge fragmentul", + "noSessionsForUser": "Nicio sesiune activă", + "deleteUserDangerDesc": "Ștergeți definitiv {{username}} și toate datele aferente (gazde, acreditări, fragmente de date, istoric). Această acțiune nu poate fi anulată.", + "deleteUserConfirm": "Ștergeți definitiv {{username}} și toate datele aferente?", + "deleteUserAdminBlocked": "Eliminați statutul de administrator înainte de a șterge acest utilizator.", "createRoleRequired": "Numele și numele afișat sunt obligatorii", "createRoleSuccess": "Rolul „{{name}}” a fost creat", "createRoleFailed": "Nu s-a putut crea rolul", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Istoricul comenzilor", "hostDefaultsCommandHistoryDesc": "Urmăriți istoricul comenzilor pe gazdele noi în mod implicit", "hostDefaultsSaved": "Setările implicite ale gazdei au fost salvate", - "hostDefaultsSaveFailed": "Nu s-au putut salva setările implicite ale gazdei" + "hostDefaultsSaveFailed": "Nu s-au putut salva setările implicite ale gazdei", + "rolePermissions": { + "count": "permisiuni {{count}}", + "editAction": "Editați permisiunile", + "loadError": "Nu s-a putut încărca catalogul de permisiuni", + "saved": "Permisiunile de rol au fost salvate", + "saveError": "Nu s-au putut salva permisiunile rolului", + "save": "Salva", + "saving": "Economisire..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Eșuat" }, "userProfile": { + "donateTitle": "Menține Termix în viață", + "donateDescription": "Termix este gratuit, găzduit în regim self-hosting și construit de doar câțiva oameni în timpul lor liber. Dacă ți-a economisit timp sau bani, o donație în criptomonede ajută la menținerea activității.", + "donateMilestones": "Donațiile ajută la finanțarea timpului necesar pentru cercetare și învățare a ceea ce este necesar pentru a dezvolta suport SAML, Kubernetes și pentru agenți. Vedeți progresul și donați.", + "donateButton": "Donează criptomonede", "storageModeLocal": "Browser", "storageModeCloud": "Bază de date", "storageModeDescription": "Browserul stochează setările doar în acest browser. Baza de date se sincronizează cu serverul și se încarcă pe orice dispozitiv.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Pe", "twoFaOff": "Dezactivat", "versionLabel": "Versiune", + "betaProgramTitle": "Programul Beta", + "betaProgramDescription": "Încercați din timp funcțiile viitoare cu eticheta Docker săptămânală :beta. Instabil, nu este pentru producție.", + "betaProgramFeedback": "Ai găsit o eroare? Raportează-o aici.", + "betaProgramLearnMore": "Află mai multe", "deleteAccount": "Șterge contul", "deleteAccountDescription": "Ștergeți definitiv contul dvs.", "changeServerDescription": "Treceți la un alt server backend Termix", diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index af4920dd..05c15e93 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -1,24 +1,24 @@ { "termixId": { - "title": "Идентификатор смеси", - "loadFailed": "Не удалось загрузить идентификатор Termix.", - "claimTitle": "Получите свой идентификатор Termix.", - "claimIntro": "Выберите уникальный никнейм. Ваши открытые SSH-ключи будут опубликованы по общедоступному URL-адресу, который вы можете добавить в файл authorized_keys любого сервера.", - "handleLabel": "Ручка", - "handlePlaceholder": "Алиса", + "title": "Termix ID", + "loadFailed": "Не удалось загрузить Termix ID", + "claimTitle": "Получить Termix ID", + "claimIntro": "Выберите уникальный идентификатор. Ваши открытые SSH-ключи будут опубликованы по общедоступному URL-адресу, который можно добавить в файл authorized_keys любого сервера.", + "handleLabel": "Идентификатор", + "handlePlaceholder": "alice", "checking": "Проверка…", - "available": "Доступный", + "available": "Доступно", "taken": "Уже занят", "invalidHandle": "Только строчные буквы, цифры, - и _", "descriptionLabel": "Описание (необязательно)", - "descriptionPlaceholder": "Рабочие клавиши для ноутбука и телефона", - "create": "Создать идентификатор Termix", - "created": "Идентификатор Termix создан", - "createFailed": "Не удалось создать идентификатор Termix.", - "deleteConfirm": "Удалить свой идентификатор Termix и все опубликованные ключи? Серверы, которые вы инициализировали, сохранят ключи до тех пор, пока вы не удалите их вручную.", - "deleted": "Идентификатор Termix удален", - "deleteFailed": "Не удалось удалить идентификатор Termix.", - "copyFailed": "Копирование не удалось", + "descriptionPlaceholder": "SSH-ключи рабочего ноутбука и телефона", + "create": "Создать Termix ID", + "created": "Termix ID создан", + "createFailed": "Не удалось создать Termix ID", + "deleteConfirm": "Удалить ваш Termix ID и все опубликованные ключи? Серверы, на которые вы добавили ключи, сохранят их, пока вы не удалите их вручную.", + "deleted": "Termix ID удален", + "deleteFailed": "Не удалось удалить Termix ID", + "copyFailed": "Ошибка копирования", "resolverUrlLabel": "Публичный URL-адрес резолвера", "provisionLabel": "Подготовка сервера", "publishTitle": "Опубликовать открытый ключ", @@ -26,10 +26,10 @@ "generateTooltip": "Сгенерируйте пару ключей Ed25519 — опубликуйте открытый ключ и загрузите закрытый ключ на ваше устройство.", "saveToVault": "Сохранить в учетные данные", "keyPlaceholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... user@host", - "labelPlaceholder": "Этикетка (необязательно)", - "add": "Добавлять", + "labelPlaceholder": "Метка (необязательно)", + "add": "Добавить", "importFromCredential": "Или импортируйте данные из сохраненных учетных данных.", - "keyPublished": "Ключевые опубликованные", + "keyPublished": "Ключ опубликован", "addKeyFailed": "Не удалось добавить ключ.", "generatedSaved": "Пара ключей сгенерирована и сохранена в хранилище ваших учетных данных. Закрытый ключ также загружен.", "generatedOnly": "Пара ключей сгенерирована — закрытый ключ загружен (отображается только один раз).", @@ -40,21 +40,21 @@ "keysTitle": "Опубликованные ключи", "published": "Опубликовано", "hidden": "Скрытый", - "keyRemoved": "Ключ извлечен", - "removeKeyFailed": "Не удалось вынуть ключ.", + "keyRemoved": "Ключ удалён", + "removeKeyFailed": "Не удалось удалить ключ", "updateKeyFailed": "Не удалось обновить ключ.", "fromVault": "Из хранилища учетных данных", "linkedToTermixId": "Опубликовано через Termix ID", "selectCredential": "Выберите учетные данные...", "import": "Импорт", "caTitle": "Центр сертификации", - "caIntro": "Доверьтесь этому центру сертификации на сервере, и он примет любой подписанный вами сертификат. Используйте функцию ротации, чтобы отозвать все сертификаты сразу; сертификаты также истекают автоматически.", + "caIntro": "Доверьте этот CA-сертификат серверу, и он будет принимать любые сертификаты, подписанные вами. Выполните ротацию, чтобы сразу отозвать все сертификаты; кроме того, сертификаты автоматически истекают.", "caEnable": "Включить CA", "caEnabled": "CA включен", "caCreateFailed": "Не удалось включить CA.", - "caPublicKeyLabel": "открытый ключ CA", + "caPublicKeyLabel": "Открытый ключ CA", "caTrustLabel": "Доверьтесь серверу (запустите его от имени root).", - "caRotate": "Повернуть", + "caRotate": "Сменить", "caRotateConfirm": "Если вы хотите сменить центр сертификации (ЦС), все подписанные им сертификаты перестанут приниматься и должны быть перевыпущены.", "caRotated": "Центр сертификации был перезапущен — предыдущие сертификаты отозваны.", "caRotateFailed": "Не удалось повернуть CA", @@ -63,7 +63,7 @@ "caDeleted": "CA удален", "caDeleteFailed": "Не удалось удалить CA", "caValidityLabel": "Срок действия сертификата (в днях)", - "issueCert": "Сертификат", + "issueCert": "Выдать сертификат", "issueCertTooltip": "Выдайте для этого ключа SSH-сертификат, подписанный вашим центром сертификации.", "certIssued": "Сертификат выдан и загружен.", "certIssueFailed": "Не удалось выдать сертификат." @@ -75,7 +75,7 @@ "key": "Ключ", "sshPrivateKey": "Закрытый ключ SSH", "upload": "Загрузить", - "keyPassword": "Ключ Пароль", + "keyPassword": "Пароль ключа", "sshKey": "Ключ SSH", "uploadPrivateKeyFile": "Загрузите файл закрытого ключа.", "searchCredentials": "Поиск учетных данных...", @@ -83,7 +83,7 @@ "caCertificate": "Сертификат CA (-cert.pub)", "caCertificateDescription": "Необязательно: загрузите или вставьте файл сертификата, подписанного центром сертификации (например, id_ed25519-cert.pub). Обязательно, если ваш SSH-сервер использует авторизацию на основе сертификатов.", "uploadCertFile": "Загрузите файл -cert.pub", - "clearCert": "Прозрачный", + "clearCert": "Очистить", "certLoaded": "Сертификат загружен", "certPublicKeyLabel": "Сертификат CA", "certTypeLabel": "Тип сертификата", @@ -118,15 +118,15 @@ "hostStatus": "Статус хоста", "bookmarkList": "Закладки", "zoomIn": "Увеличить масштаб", - "zoomOut": "Отдалиться", + "zoomOut": "Уменьшить масштаб", "resetView": "Сбросить вид", - "lockLayout": "Схема расположения замков", + "lockLayout": "Закрепить макет", "unlockLayout": "Разблокировать макет", "noWidgets": "Щелкните правой кнопкой мыши или нажмите +, чтобы добавить первый виджет.", "openFullView": "Открыть полный просмотр", "previewTitle": "Предварительный просмотр главной страницы", "cancel": "Отмена", - "save": "Сохранять", + "save": "Сохранить", "title_label": "Заголовок", "widgetTitlePlaceholder": "Заголовок виджета (необязательно)", "url": "URL", @@ -135,7 +135,7 @@ "showImage": "Показать изображение", "description": "Описание", "color": "Цвет", - "icon": "Икона", + "icon": "Значок", "expanded": "Развернуто по умолчанию", "timezone": "Часовой пояс", "showSeconds": "Показать секунды", @@ -143,13 +143,13 @@ "format24h": "24 часа", "content": "Содержание", "backgroundColor": "Цвет фона", - "host": "Хозяин", + "host": "Хост", "showMetrics": "Показать метрики", "links": "Ссылки", "addLink": "Добавить ссылку", "linkLabel": "Этикетка", "linkUrl": "URL", - "removeLink": "Удалять", + "removeLink": "Удалить", "categoryLinks": "Ссылки", "categoryInfo": "Информация", "categorySystem": "Система", @@ -166,7 +166,7 @@ "showForecast": "Показать прогноз на 3 дня", "scrolling": "Разрешить прокрутку", "feedUrl": "URL ленты", - "maxItems": "Максимальное количество предметов", + "maxItems": "Максимальное количество объектов", "showDescription": "Показать описание", "widgetWeatherName": "Погода", "widgetWeatherDesc": "Актуальная информация о погоде в любом месте.", @@ -191,13 +191,13 @@ "metricHostname": "Имя хоста", "metricNetwork": "Сеть", "metricProcesses": "Процессы", - "metricProcessesTotal": "общий", - "metricProcessesRunning": "бег", + "metricProcessesTotal": "всего", + "metricProcessesRunning": "запущено", "categoryMonitoring": "Мониторинг", "loading": "Загрузка...", "noData": "Данные отсутствуют.", "allClear": "Все чисто", - "acknowledgeAlert": "Сознавать", + "acknowledgeAlert": "Подтвердить", "noPingUrls": "URL-адреса не настроены", "pingLabel": "Этикетка", "addPingUrl": "Добавить URL", @@ -215,7 +215,7 @@ "overviewDatabase": "База данных", "overviewUptime": "Время безотказной работы", "noHosts": "Хосты не настроены", - "hostGridHosts": "Хозяева", + "hostGridHosts": "Хосты", "hostGridAllHint": "Оставьте поле пустым, чтобы отобразить все хосты.", "columns": "Колонки", "showIp": "Показать IP-адрес", @@ -234,11 +234,11 @@ "widgetHostGridName": "Сетка хостов", "widgetHostGridDesc": "Табличное отображение статусов SSH-хостов", "widgetAlertFeedName": "Лента оповещений", - "widgetAlertFeedDesc": "Стрельба в режиме реального времени с подтверждением получения сигнала", + "widgetAlertFeedDesc": "Лента оповещений в реальном времени с возможностью подтверждения", "widgetPingStatusName": "Статус пинга", "widgetPingStatusDesc": "Статус HTTP-пинга для одного или нескольких URL-адресов", - "widgetRecentActivityName": "Последние события", - "widgetRecentActivityDesc": "Лента с прокруткой последних событий в Termix", + "widgetRecentActivityName": "Недавняя активность", + "widgetRecentActivityDesc": "Прокручиваемая лента недавней активности Termix", "widgetTermixUptimeName": "Termix Uptime", "widgetTermixUptimeDesc": "Счетчик времени безотказной работы сервера Termix в реальном времени", "widgetSystemOverviewName": "Обзор системы", @@ -246,7 +246,7 @@ "widgetSshQuickConnectName": "Быстрое подключение по SSH", "widgetSshQuickConnectDesc": "Кнопки для открытия SSH-сессий одним нажатием", "widgetDockerActivityName": "Docker Activity", - "widgetDockerActivityDesc": "Последние события контейнеров Docker на всех хостах", + "widgetDockerActivityDesc": "Последние события Docker-контейнеров на всех хостах", "widgetCalendarName": "Календарь", "widgetCalendarDesc": "Ежемесячный календарь с выделенным сегодняшним днем.", "widgetCountdownName": "Обратный отсчет", @@ -258,7 +258,7 @@ "widgetImageWidgetName": "Изображение", "widgetImageWidgetDesc": "Отобразить изображение по URL-адресу", "widgetMarkdownNotesName": "Заметки по разметке Markdown", - "widgetMarkdownNotesDesc": "Насыщенные заметки с отображением в формате Markdown непосредственно в тексте", + "widgetMarkdownNotesDesc": "Форматированные заметки с поддержкой встроенной Markdown-разметки", "widgetCustomApiName": "Пользовательский API", "widgetCustomApiDesc": "Получение и отображение данных из любого JSON API", "widgetServiceGridName": "Сервисная сеть", @@ -266,7 +266,7 @@ "widgetDashboardLinksName": "Ссылки на панели управления", "widgetDashboardLinksDesc": "Отобразите настроенные вами ссылки на сервисы на панели управления.", "widgetSearchLinksName": "Ярлыки поиска", - "widgetSearchLinksDesc": "Кнопки быстрого поиска с возможностью ввода текста в строку", + "widgetSearchLinksDesc": "Кнопки быстрого поиска со встроенным полем ввода", "widgetLinkTreeName": "Дерево ссылок", "widgetLinkTreeDesc": "Сгруппированные разделы ссылок с заголовками", "calMon": "Мо", @@ -285,13 +285,13 @@ "countdownSeconds": "сек", "countdownLabel": "Этикетка", "countdownLabelPlaceholder": "например, день запуска", - "countdownShowDays": "Дни шоу", - "countdownShowHours": "Часы работы выставки", + "countdownShowDays": "Показывать дни", + "countdownShowHours": "Показывать часы", "targetDate": "Целевая дата", "searchEngine": "Поисковая система", "customSearchUrl": "Пользовательский URL-адрес поиска", "searchPlaceholder": "Поиск...", - "searchGo": "Идти", + "searchGo": "Перейти", "searchPlaceholderLabel": "Текст-заполнитель", "searchPlaceholderHint": "Текст, отображаемый внутри поля поиска.", "openInNewTab": "Открыть в новой вкладке", @@ -343,11 +343,11 @@ "filterAll": "Все", "accentColor": "Акцентный цвет", "widgetSshTerminalName": "Терминал SSH", - "widgetSshTerminalDesc": "Встроенный SSH-терминал, подключенный к настроенному хосту.", + "widgetSshTerminalDesc": "Встроенный SSH-терминал с подключением к настроенному хосту", "sshTerminalNoHost": "Хост не настроен", - "sshTerminalConnect": "Соединять", + "sshTerminalConnect": "Подключиться", "sshTerminalAutoConnect": "Автоматическое подключение под нагрузкой", - "widgetQuickConnectName": "Быстрое подключение", + "widgetQuickConnectName": "Быстрый вход", "widgetQuickConnectDesc": "Кнопки запуска в один клик для любого типа подключения на всех ваших хостах.", "connectionTypes": "Типы подключения", "connType_terminal": "Терминал", @@ -355,8 +355,8 @@ "connType_docker": "Docker", "connType_tunnel": "Туннель", "connType_host-metrics": "Метрики хоста", - "connType_rdp": "РДП", - "connType_vnc": "ВНК", + "connType_rdp": "RDP", + "connType_vnc": "VNC", "connType_telnet": "Телнет", "widgetFileManagerName": "Файловый менеджер", "widgetFileManagerDesc": "Встроенный файловый менеджер SFTP для настроенного хоста", @@ -387,7 +387,7 @@ "localServer": "Локальный сервер", "savedServers": "Сохраненные серверы", "noSavedServers": "Сохраненные серверы отсутствуют", - "removeServer": "Удалять" + "removeServer": "Удалить" }, "versionCheck": { "error": "Ошибка проверки версии", @@ -406,21 +406,21 @@ "updateRequired": "Требуется обновление" }, "common": { - "close": "Закрывать", - "minimize": "Свести к минимуму", + "close": "Закрыть", + "minimize": "Свернуть", "online": "Онлайн", "offline": "Офлайн", "unknown": "Неизвестный", - "continue": "Продолжать", + "continue": "Продолжить", "maintenance": "Обслуживание", - "degraded": "Деградировавший", + "degraded": "Снижена производительность", "error": "Ошибка", "warning": "Предупреждение", "unsavedChanges": "Несохраненные изменения", - "dismiss": "Увольнять", + "dismiss": "Скрыть", "loading": "Загрузка...", "optional": "Необязательный", - "connect": "Соединять", + "connect": "Подключиться", "copied": "Скопировано", "connecting": "Подключение...", "updateAvailable": "Доступно обновление", @@ -434,19 +434,19 @@ "noReleasesFound": "Релизы не найдены.", "cancel": "Отмена", "username": "Имя пользователя", - "login": "Авторизоваться", + "login": "Войти", "logout": "Выйти", - "register": "Регистрация", + "register": "Зарегистрироваться", "password": "Пароль", "confirmPassword": "Подтвердите пароль", "back": "Назад", - "save": "Сохранять", + "save": "Сохранить", "saving": "Сохранение...", "delete": "Удалить", "rename": "Переименовать", "edit": "Редактировать", - "add": "Добавлять", - "confirm": "Подтверждать", + "add": "Добавить", + "confirm": "Подтвердить", "no": "Нет", "or": "ИЛИ", "next": "Следующий", @@ -458,13 +458,13 @@ "checkingAuthentication": "Проверка аутентификации...", "backendReconnected": "Соединение с сервером восстановлено.", "connectionDegraded": "Соединение с сервером потеряно, восстанавливаем…", - "reload": "Перезагрузка", - "remove": "Удалять", - "create": "Создавать", - "update": "Обновлять", + "reload": "Перезагрузить", + "remove": "Удалить", + "create": "Создать", + "update": "Обновить", "copy": "Копия", "copyFailed": "Не удалось скопировать в буфер обмена.", - "maximize": "Максимизировать", + "maximize": "Развернуть", "restore": "Восстановить", "of": "из", "saved": "Сохранено", @@ -474,7 +474,7 @@ "required": "Необходимый" }, "nav": { - "home": "Дом", + "home": "Home", "terminal": "Терминал", "docker": "Docker", "tunnels": "Туннели", @@ -482,11 +482,11 @@ "serverStats": "Метрики хоста", "hostMetrics": "Метрики хоста", "admin": "Администратор", - "termixId": "ИДЕНТИФИКАТОР", - "userProfile": "Профиль пользователя", - "splitScreen": "Разделенный экран", + "termixId": "ID", + "userProfile": "Профиль", + "splitScreen": "Сплит экрана", "confirmClose": "Закрыть активную сессию?", - "close": "Закрывать", + "close": "Закрыть", "cancel": "Отмена", "sshManager": "Менеджер SSH", "cannotSplitTab": "Невозможно разделить эту вкладку", @@ -500,25 +500,25 @@ "renameTab": "Переименовать вкладку", "openFileManager": "Открыть файловый менеджер", "dashboard": "Панель управления", - "networkGraph": "Сетевой граф", + "networkGraph": "Карта сети", "tmuxMonitor": "Монитор Tmux", "homepage": "Главная страница", "quickConnect": "Быстрое подключение", "sshTools": "Инструменты SSH", "history": "История", "sessionLogs": "Журналы сессий", - "hosts": "Хозяева", - "snippets": "Фрагменты", + "hosts": "Хосты", + "snippets": "Фрагменты кода", "hostManager": "Менеджер хоста", - "credentials": "Реквизиты для входа", - "connections": "Связи", + "credentials": "Учётные данные", + "connections": "Сессии", "alerts": "Оповещения", - "serial": "Серийный номер", + "serial": "COM-порт", "roleAdministrator": "Администратор", "roleUser": "Пользователь" }, "hosts": { - "hosts": "Хозяева", + "hosts": "Хосты", "noHosts": "Нет SSH-хостов", "retry": "Повторить попытку", "refresh": "Обновить", @@ -535,7 +535,7 @@ "username": "Имя пользователя", "folder": "Папка", "tags": "Теги", - "pin": "Приколоть", + "pin": "Закрепить", "addHost": "Добавить хост", "editHost": "Редактировать хост", "cloneHost": "Клон хоста", @@ -544,12 +544,12 @@ "enableFileManager": "Включить файловый менеджер", "enableDocker": "Включить Docker", "defaultPath": "Путь по умолчанию", - "connection": "Связь", + "connection": "Соединение", "upload": "Загрузить", "authentication": "Аутентификация", "password": "Пароль", "key": "Ключ", - "credential": "Удостоверение личности", + "credential": "Учётные данные", "none": "Никто", "sshPrivateKey": "Закрытый ключ SSH", "keyType": "Тип ключа", @@ -557,15 +557,15 @@ "tabGeneral": "Общий", "tabSsh": "SSH", "tabTerminal": "Терминал", - "tabRdp": "РДП", - "tabVnc": "ВНК", + "tabRdp": "RDP", + "tabVnc": "VNC", "tabTunnels": "Туннели", "tabDocker": "Docker", "tabFiles": "Файлы", "tabStats": "Метрики хоста", "tabHostMetrics": "Метрики хоста", "tabTelnet": "Телнет", - "tabSharing": "Совместное использование", + "tabSharing": "Общий доступ", "tabAuthentication": "Аутентификация", "terminal": "Терминал", "tunnel": "Туннель", @@ -590,7 +590,7 @@ "cursorStyle": "Стиль курсора", "cursorBlink": "Мерцание курсора", "scrollbackBuffer": "Буфер прокрутки", - "bellStyle": "Белл Стайл", + "bellStyle": "Стиль звукового сигнала", "rightClickSelectsWord": "Щелчок правой кнопкой мыши выделяет слово", "fastScrollModifier": "Модификатор быстрой прокрутки", "fastScrollSensitivity": "Чувствительность быстрой прокрутки", @@ -603,7 +603,7 @@ "overrideCredentialUsernameDesc": "Вместо имени пользователя, указанного в учетных данных, используйте имя пользователя, указанное выше.", "oidcUsernameHint": "Используйте $oidc.preferred_username для замены вашего имени пользователя OIDC.", "jumpHostChain": "Цепочка переходов хостов", - "portKnocking": "Порт-Нокинг", + "portKnocking": "Port knocking", "addKnock": "Добавить порт", "addProxyNode": "Добавить узел", "proxyNode": "Прокси-узел", @@ -611,23 +611,23 @@ "quickActions": "Быстрые действия", "sudoPasswordAutoFill": "Автозаполнение паролей Sudo", "sudoPassword": "Пароль Sudo", - "keepaliveInterval": "Интервал поддержания соединения (мс)", - "moshCommand": "Команда МОШ", - "environmentVariables": "Переменные окружающей среды", + "keepaliveInterval": "Интервал проверки активности (мс)", + "moshCommand": "Команда Mosh", + "environmentVariables": "Переменные среды", "addVariable": "Добавить переменную", "docker": "Docker", "copyTerminalUrl": "Скопировать URL терминала", "copyFileManagerUrl": "Скопировать URL-адрес файлового менеджера", "copyRemoteDesktopUrl": "Скопировать URL-адрес удаленного рабочего стола", "failedToConnect": "Не удалось подключиться к консоли", - "connect": "Соединять", + "connect": "Подключиться", "disconnect": "Отключить", - "start": "Начинать", + "start": "Запустить", "enableStatusCheck": "Включить проверку состояния", "enableMetrics": "Включить метрики", "bulkUpdateFailed": "Сбой массового обновления", "selectAll": "Выбрать все", - "deselectAll": "Снять выделение со всех элементов", + "deselectAll": "Снять выделение со всех объектов", "protocols": "Протоколы", "secureShell": "Secure Shell", "virtualNetwork": "Виртуальная сеть", @@ -635,17 +635,17 @@ "addressIp": "Адрес / IP-адрес", "friendlyName": "Дружественное имя", "macAddress": "MAC-адрес", - "wolBroadcastAddress": "Обращение WoL в эфире", + "wolBroadcastAddress": "Широковещательный адрес WoL", "wolBroadcastAddressDesc": "Дополнительная опция для направленной широковещательной рассылки в сетях Docker/маршрутизируемых сетях (например, 192.168.1.255). Оставьте поле пустым, чтобы использовать 255.255.255.255.", "folderAndAdvanced": "Папка и расширенные настройки", "privateNotes": "Личные заметки", "privateNotesPlaceholder": "Подробная информация об этом сервере...", "pinToTop": "Закрепить вверху", "pinToTopDesc": "Этот хост всегда должен отображаться вверху списка.", - "portKnockingSequence": "Последовательность стук в порт", + "portKnockingSequence": "Последовательность port knocking", "addKnockBtn": "Добавить стук", "noPortKnocking": "Функция \"вызова порта\" не настроена.", - "knockPort": "Порт для стук", + "knockPort": "Порт Port Knocking", "protocol": "Протокол", "delayAfterMs": "Задержка (мс)", "useSocks5Proxy": "Используйте прокси SOCKS5", @@ -656,7 +656,7 @@ "proxyPassword": "Пароль прокси", "proxySingleMode": "Единый прокси", "proxyChainMode": "Цепочка прокси", - "you": "Ты", + "you": "Вы", "jumpHostChainLabel": "Цепочка переходов хостов", "addJumpBtn": "Добавить прыжок", "noJumpHosts": "Переходные хосты не настроены.", @@ -701,16 +701,16 @@ "keyPassphraseSaved": "Кодовая фраза сохранена, введите для изменения.", "replaceKey": "Заменить ключ", "docsLink": "Просмотреть документацию", - "opksshLabel": "ОПКШ", + "opksshLabel": "OPKSSH", "opksshDesc": "Войдите на этот хост, используя свой поставщик идентификации вместо пароля или ключа. Требуется настройка OPKSSH на сервере.", - "warpgateLabel": "Врата Варпа", + "warpgateLabel": "Warpgate Gateway", "warpgateDesc": "Этот хост подключается через SSH-прокси Warpgate. Termix автоматически обработает процесс подтверждения через браузер после аутентификации.", "agentLabel": "Агент SSH", "agentDesc": "Аутентификация должна осуществляться с помощью SSH-агента, работающего на сервере Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агент должен быть запущен на машине, где размещен Termix.", "agentSocketPathLabel": "Путь к сокету агента", "agentSocketPathPlaceholder": "Оставьте поле пустым, чтобы использовать SSH_AUTH_SOCK.", "agentSocketPathHint": "Оставьте поле пустым для автоматического определения пути к сокету из переменной среды SSH_AUTH_SOCK или введите собственный путь к сокету (например, /run/user/1000/gnupg/S.gpg-agent.ssh).", - "tailscaleDeviceSelect": "Выберите устройство \"Хвостовая шкала\"", + "tailscaleDeviceSelect": "Выберите устройство Tailscale", "tailscaleDeviceSelectPlaceholder": "Выберите устройство...", "tailscaleNoApiKey": "Ключ API Tailscale не настроен. Добавьте его в настройках администратора, чтобы включить обнаружение устройств.", "tailscaleDocsLink": "Просмотреть документацию", @@ -729,7 +729,7 @@ "cursorStyleLabel": "Стиль курсора", "letterSpacingPx": "Межбуквенный интервал (px)", "lineHeightLabel": "Высота строки", - "bellStyleLabel": "Белл Стайл", + "bellStyleLabel": "Стиль звукового сигнала", "backspaceModeLabel": "Режим Backspace", "cursorBlinking": "Мигание курсора", "cursorBlinkingDesc": "Включить анимацию мигания для курсора терминала", @@ -791,26 +791,26 @@ "enableAutoTmuxDesc": "Автоматический запуск или подключение к сессии tmux", "enableSessionLogging": "Ведение журнала сессий", "enableSessionLoggingDesc": "Запишите вывод терминала для последующего анализа.", - "enableCommandHistory": "История командования", - "enableCommandHistoryDesc": "В этом терминале записываются команды для отображения истории и автозаполнения.", + "enableCommandHistory": "История команд", + "enableCommandHistoryDesc": "Сохранять выполненные в этом терминале команды для истории и автодополнения", "linkClickBehaviorLabel": "Поведение при клике по ссылке", "linkClickBehaviorDesc": "Управляет действиями при нажатии на ссылку в терминале. Используйте значение «По умолчанию», чтобы следовать настройкам на уровне приложения.", "linkClickBehaviorDefault": "По умолчанию (в соответствии с настройками приложения)", - "linkClickBehaviorConfirm": "Показать всплывающее окно для открытия или копирования", + "linkClickBehaviorConfirm": "Показать окно открытия или копирования", "linkClickBehaviorDirect": "Открыть напрямую", "sudoPasswordAutoFillLabel": "Автозаполнение паролей Sudo", "sudoPasswordAutoFillShortDesc": "Автоматически вводить пароль sudo при появлении соответствующего запроса.", "sudoPasswordAutoFillDesc": "Сохраните пароль для команды sudo, чтобы метрики хоста, командная строка терминала и другие средства могли автоматически выполнять привилегированные команды.", "sudoPasswordLabel": "Пароль Sudo", - "environmentVariablesLabel": "Переменные окружающей среды", + "environmentVariablesLabel": "Переменные среды", "addVariableBtn": "Добавить переменную", "noEnvVars": "Переменные среды не настроены.", "fastScrollModifierLabel": "Модификатор быстрой прокрутки", "fastScrollSensitivityLabel": "Чувствительность быстрой прокрутки", - "moshCommandLabel": "Команда Мош", + "moshCommandLabel": "Команда Mosh", "startupSnippetLabel": "Фрагмент запуска", - "keepaliveIntervalLabel": "Интервал поддержания соединения (секунды)", - "maxKeepaliveMisses": "Макс Кипалайв промахнулся", + "keepaliveIntervalLabel": "Интервал проверки активности (секунды)", + "maxKeepaliveMisses": "Максимальное количество пропущенных проверок активности", "tunnelSettings": "Настройки туннеля", "enableTunneling": "Включить туннелирование", "enableTunnelingDesc": "Включить функцию SSH-туннелирования для этого хоста.", @@ -842,10 +842,10 @@ "containerRuntime": "Среда выполнения контейнеров", "containerRuntimeDesc": "Выберите интерфейс командной строки, используемый для управления контейнерами на этом хосте.", "containerRuntimeDocker": "Docker", - "containerRuntimePodman": "Подман", + "containerRuntimePodman": "Podman", "enableTmuxMonitor": "Включить монитор Tmux", "enableTmuxMonitorDesc": "Отобразить этот хост в мониторе Tmux и добавить его действия Tmux на боковую панель.", - "tabProxmox": "Проксмокс", + "tabProxmox": "Proxmox", "proxmoxIntegration": "Интеграция с Proxmox", "enableProxmox": "Включить Proxmox", "enableProxmoxDesc": "Отметьте этот хост как узел Proxmox. Это позволит обнаруживать и импортировать гостевые системы непосредственно с этого хоста.", @@ -853,8 +853,8 @@ "proxmoxDefaultAuthTypeDesc": "Метод аутентификации, применяемый к импортированным гостевым хостам. Выберите тип аутентификации, соответствующий способу подключения к вашим гостям.", "authTypePassword": "Пароль", "authTypeKey": "Ключ SSH", - "authTypeCredential": "Удостоверение личности", - "authTypeOpkssh": "ОПКШ", + "authTypeCredential": "Учётные данные", + "authTypeOpkssh": "OPKSSH", "authTypeNone": "Никто", "proxmoxDefaultCredential": "Учетные данные по умолчанию", "proxmoxDefaultCredentialDesc": "Учетные данные используются для импортированных гостевых хостов, если для типа аутентификации установлено значение «Учетные данные».", @@ -864,28 +864,43 @@ "proxmoxDockerDetectionDesc": "Шаблоны имен, разделенные запятыми, которые позволяют Docker сопоставлять гостевые системы.", "proxmoxPreferredRanges": "Предпочтительные диапазоны IP-адресов", "proxmoxPreferredRangesDesc": "Префиксы, разделённые запятыми, в порядке приоритета для выбора IP-адреса, когда у гостевой системы несколько сетевых интерфейсов.", - "proxmoxDiscoverAction": "Откройте для себя и импортируйте гостей Proxmox.", + "proxmoxAutoSync": "Автоматическая синхронизация гостей", + "proxmoxAutoSyncDesc": "Периодически устанавливайте соединение с этим узлом Proxmox и создавайте или обновляйте импортированные гостевые хосты, пока ваша сессия разблокирована.", + "proxmoxSyncInterval": "Интервал синхронизации (минуты)", + "proxmoxSyncIntervalDesc": "Минимум 5 минут. Планировщик пропускает заблокированные пользовательские сессии.", + "proxmoxMarkMissing": "Отметьте отсутствующих гостей", + "proxmoxMarkMissingDesc": "Вместо удаления ранее импортированного гостя, добавьте тег proxmox-missing.", + "proxmoxLastSync": "Последняя синхронизация", + "proxmoxLastSyncNever": "Еще не синхронизировано", + "proxmoxLastSyncNoResult": "Результаты отсутствуют.", + "proxmoxLastSyncSummary": "{{created}} создано, {{updated}} обновлено, {{markedMissing}} отсутствует, {{skipped}} пропущено", + "proxmoxLastSyncStatus": { + "success": "Успех", + "error": "Неуспешный", + "pending": "В ожидании" + }, + "proxmoxDiscoverAction": "Обнаружение и импорт гостевых систем Proxmox", "proxmoxImportTitle": "Импорт из Proxmox", "proxmoxSelectHost": "Выберите хост Proxmox…", - "proxmoxDiscover": "Обнаружить", - "proxmoxDiscovering": "Открытие…", - "proxmoxDiscoverGuests": "Узнайте больше о гостях", + "proxmoxDiscover": "Найти", + "proxmoxDiscovering": "Обнаружение…", + "proxmoxDiscoverGuests": "Обнаружить гостевые системы", "proxmoxGuestsSelected_one": "{{count}} гость — {{selected}} выбран", "proxmoxGuestsSelected_other": "{{count}} гостей — {{selected}} выбраны", "proxmoxSelectAll": "Выбрать все", - "proxmoxDeselectAll": "Снять выделение со всех элементов", + "proxmoxDeselectAll": "Снять выделение со всех объектов", "proxmoxNoGuests": "На этом узле Proxmox не обнаружено ни одного гостя.", - "proxmoxImportButton_one": "Импорт {{count}} хост", + "proxmoxImportButton_one": "Импорт {{count}} хоста", "proxmoxImportButton_other": "Импорт {{count}} хостов", "proxmoxResultImported": "{{count}} импортировано", "proxmoxResultUpdated": "{{count}} обновлено", - "proxmoxResultFailed": "{{count}} провалился", + "proxmoxResultFailed": "{{count}} не удалось", "proxmoxImportComplete": "Импорт в Proxmox завершен: {{summary}}", - "proxmoxDiscoveryFailed": "Обнаружение не удалось.", + "proxmoxDiscoveryFailed": "Не удалось обнаружить", "proxmoxImportFailed": "Импорт не удался", "enableFileManagerMonitor": "Включить файловый менеджер", "enableFileManagerMonitorDesc": "Просматривайте и управляйте файлами на этом хосте по протоколу SFTP.", - "scpLegacyLabel": "Режим наследия SCP", + "scpLegacyLabel": "Устаревший режим SCP", "scpLegacyDesc": "Для серверов, не поддерживающих подсистему SFTP (например, встроенных или минималистичных SSH-серверов), используйте устаревшие методы передачи файлов.", "defaultPathLabel": "Путь по умолчанию", "fileManagerPathHint": "Каталог, который будет открыт при запуске файлового менеджера для данного хоста.", @@ -907,7 +922,7 @@ "widgetsMovedToHostMetrics": "Теперь карточки добавляются, упорядочиваются и изменяют размер непосредственно на вкладке «Метрики хоста». Откройте «Метрики хоста» для этого хоста и используйте функцию «Настроить», чтобы выбрать, какие карточки будут отображаться.", "cpuUsageLabel": "Использование ЦП", "cpuUsageDesc": "Процент использования ЦП, средняя загрузка, спарклайн-график", - "memoryLabel": "Использование памяти", + "memoryLabel": "Использование ОЗУ", "memoryDesc": "Использование оперативной памяти, файла подкачки, кэша", "storageLabel": "Использование диска", "storageDesc": "Использование дискового пространства на точку монтирования", @@ -946,7 +961,7 @@ "failedToSaveCredential": "Не удалось сохранить учетные данные.", "credentialNameRequired": "Пожалуйста, введите имя для учетных данных.", "credentialAuthRequired": "Добавьте пароль, SSH-ключ или и то, и другое.", - "backToHosts": "Вернуться к списку организаторов", + "backToHosts": "Вернуться к списку хостов", "backToCredentials": "Вернуться к учетным данным", "pinned": "Закреплено", "noHostsFound": "Хосты не найдены", @@ -961,7 +976,7 @@ "exitSelection": "Выбор выхода", "importSkip": "Импорт (пропустить существующие)", "importOverwrite": "Импорт (перезапись)", - "collapseBtn": "Крах", + "collapseBtn": "Свернуть", "importExportBtn": "Импорт / Экспорт", "hostStatusesRefreshed": "Статусы хостов обновлены.", "failedToRefreshHosts": "Не удалось обновить хосты.", @@ -984,7 +999,7 @@ "folderNamePlaceholder": "например, производство/веб-разработка", "folderNestingHint": "Используйте символ / для разделения уровней и создания вложенных папок.", "folderColor": "Цвет", - "folderIcon": "Икона", + "folderIcon": "Значок", "folderPreview": "Предварительный просмотр", "folderNameFallback": "Папка без названия", "createFolderButton": "Создать папку", @@ -1043,7 +1058,7 @@ "deletedCredential": "Удалено {{name}}", "deploySSHKeyTitle": "Развернуть SSH-ключ", "deployingBtn": "Развертывание...", - "deployBtn": "Развертывать", + "deployBtn": "Развернуть", "failedToDeployKey": "Не удалось развернуть ключ.", "deleteHostsConfirm": "Удалить хост {{count}}{{plural}}? Это действие необратимо.", "movedToRoot": "Перемещено в корневой каталог", @@ -1080,16 +1095,16 @@ "deleteHostConfirm": "Удалить \"{{name}}\"?", "enableAtLeastOneProtocol": "Включите хотя бы один из указанных выше протоколов, чтобы настроить параметры аутентификации и подключения.", "keyPassphrase": "Ключевая кодовая фраза", - "connectBtn": "Соединять", + "connectBtn": "Подключиться", "disconnectBtn": "Отключить", "basicInformation": "Основная информация", "authDetailsSection": "Данные аутентификации", "credTypeLabel": "Тип", - "hostsTab": "Хозяева", - "credentialsTab": "Реквизиты для входа", + "hostsTab": "Хосты", + "credentialsTab": "Учётные данные", "selectMultiple": "Выберите несколько", "selectHosts": "Выберите хосты", - "connectionLabel": "Связь", + "connectionLabel": "Соединение", "authenticationLabel": "Аутентификация", "generateKeyPairTitle": "Сгенерировать пару ключей", "generateKeyPairDescription": "Сгенерируйте новую пару ключей; закрытый и открытый ключи будут заполнены автоматически.", @@ -1110,8 +1125,8 @@ "noFolderOption": "(Без папки)", "nSelected": "{{count}} выбрано", "featuresMenu": "Функции", - "moveMenu": "Двигаться", - "connectSelected": "Соединять", + "moveMenu": "Переместить", + "connectSelected": "Подключиться", "cancelSelection": "Отмена", "deployDialogDesc": "Развернуть {{name}} в authorized_keys хоста.", "targetHostLabel": "Целевой хост", @@ -1128,7 +1143,7 @@ "loadingHost": "Загрузка хоста...", "loadingHosts": "Загрузка хостов...", "loadingCredentials": "Загрузка учетных данных...", - "noHostsYet": "Хозяев пока нет.", + "noHostsYet": "Хостов пока нет.", "noHostsMatchSearch": "Поиск не дал результатов.", "hostNotFound": "Хост не найден", "searchHosts": "Поиск хостов...", @@ -1150,13 +1165,13 @@ "filterAuthGroup": "Тип аутентификации", "filterAuthPassword": "Пароль", "filterAuthKey": "Ключ SSH", - "filterAuthCredential": "Удостоверение личности", + "filterAuthCredential": "Учётные данные", "filterAuthNone": "Никто", - "filterAuthOpkssh": "ОПКШ", + "filterAuthOpkssh": "OPKSSH", "filterProtocolGroup": "Протокол", "filterProtocolSsh": "SSH", - "filterProtocolRdp": "РДП", - "filterProtocolVnc": "ВНК", + "filterProtocolRdp": "RDP", + "filterProtocolVnc": "VNC", "filterProtocolTelnet": "Телнет", "filterFeaturesGroup": "Функции", "filterFeatureTerminal": "Терминал", @@ -1164,19 +1179,69 @@ "filterFeatureTunnel": "Туннель", "filterFeatureDocker": "Docker", "filterTagsGroup": "Теги", - "shareHost": "Совместный хостинг", + "shareHost": "Поделиться хостом", "shareHostTitle": "Поделиться: {{name}}", "sharing": { - "requiresCredential": "Для включения общего доступа этому хосту необходимы учетные данные. Сначала отредактируйте хост и назначьте учетные данные." + "loadError": "Не удалось загрузить данные для обмена. Пожалуйста, попробуйте еще раз.", + "shareWithSection": "Поделиться с", + "usersTab": "Пользователи", + "rolesTab": "Роли", + "searchPlaceholder": "Поиск пользователей или ролей...", + "noMatches": "Совпадений не найдено", + "permissionLevelLabel": "Уровень доступа", + "levels": { + "connect": { + "label": "Соединять", + "description": "Открыты только следующие сессии: терминал, удалённый рабочий стол, файловый менеджер, туннели и Docker. Доступ к конфигурации хоста запрещён." + }, + "view": { + "label": "Вид", + "description": "Подключитесь и просмотрите конфигурацию хоста. Секретные данные никогда не отображаются." + }, + "edit": { + "label": "Редактировать", + "description": "Просмотр и изменение хоста. Секреты можно заменить, но их нельзя прочитать; назначение учетных данных остается только у владельца." + }, + "manage": { + "label": "Управлять", + "description": "Редактировать, а также предоставлять доступ к хосту другим пользователям, изменять уровни разрешений и отзывать доступ." + } + }, + "expiryLabel": "Срок действия доступа", + "expiry": { + "never": "Никогда", + "oneHour": "1 час", + "oneDay": "24 часа", + "sevenDays": "7 дней", + "thirtyDays": "30 дней", + "custom": "Обычай" + }, + "customHoursPlaceholder": "Осталось часов до истечения срока доступа", + "shareButton": "Делиться", + "shareWithCount": "Поделиться ({{count}})", + "currentAccess": "Текущий доступ", + "noAccessEntries": "Этот хост еще не был предоставлен другим пользователям.", + "grantedBy": "Утверждено", + "expires": "Срок действия истекает", + "expired": "Истекший", + "never": "Никогда", + "revoke": "Отменить", + "accessUpdated": "Доступ обновлен", + "accessUpdateFailed": "Не удалось обновить доступ", + "sharedBadge": "Общий", + "sharedBadgeTooltip": "Доступ предоставлен пользователями {{owner}} ({{level}}).", + "viewOnlyBanner": "Этот хост предоставлен вам пользователем {{owner}} с правами просмотра. Конфигурация доступна только для чтения.", + "sharedEditBanner": "Этот хост предоставлен вам пользователем {{owner}} с правами редактирования. Изменения применяются к реальному хосту; ссылки аутентификации может изменить только владелец.", + "ownerOnlyControl": "Изменить это может только владелец хостинга." }, "guac": { - "connection": "Связь", + "connection": "Соединение", "authentication": "Аутентификация", "storedCredential": "Сохраненные учетные данные", "noCredential": "Без учетных данных (прямые учетные данные указаны ниже)", "authMethod": "Метод аутентификации", "authTypeDirect": "Прямой", - "authTypeCredential": "Удостоверение личности", + "authTypeCredential": "Учётные данные", "selectCredential": "Выберите учетные данные...", "connectionSettings": "Настройки подключения", "displaySettings": "Настройки дисплея", @@ -1184,14 +1249,14 @@ "rdpPerformance": "Производительность RDP", "deviceRedirection": "Перенаправление устройства", "session": "Сессия", - "gateway": "Врата", + "gateway": "Шлюз", "remoteApp": "RemoteApp", "clipboard": "Буфер обмена", "sessionRecording": "Запись сессии", "wakeOnLan": "Wake-on-LAN", "vncSettings": "Настройки VNC", "terminalSettings": "Настройки терминала", - "rdpPort": "Порт РДП", + "rdpPort": "Порт RDP", "username": "Имя пользователя", "password": "Пароль", "domain": "Домен", @@ -1199,7 +1264,7 @@ "colorDepth": "Глубина цвета", "width": "Ширина", "height": "Высота", - "dpi": "ДПИ", + "dpi": "DPI", "resizeMethod": "Метод изменения размера", "clientName": "Имя клиента", "initialProgram": "Начальная программа", @@ -1210,15 +1275,15 @@ "guacdProxy": "guacd Proxy", "guacdHostname": "guacd Host", "guacdHostnamePlaceholder": "Глобальные значения по умолчанию", - "guacdPort": "порт гуакад", + "guacdPort": "Порт guacd", "guacdProxyDesc": "Переопределите глобальный экземпляр guacd для этого соединения. Оставьте поле пустым, чтобы использовать глобально настроенный guacd.", "gatewayHostname": "Имя хоста шлюза", - "gatewayPort": "Порт-ворота", + "gatewayPort": "Порт шлюза", "gatewayUsername": "Имя пользователя шлюза", "gatewayPassword": "Пароль шлюза", "gatewayDomain": "Домен шлюза", "remoteAppProgram": "Программа RemoteApp", - "workingDirectory": "Рабочий справочник", + "workingDirectory": "Рабочий каталог", "arguments": "Аргументы", "normalizeLineEndings": "Нормализация символов конца строки", "recordingPath": "Путь записи", @@ -1228,7 +1293,7 @@ "udpPort": "Порт UDP", "waitTimeS": "Время ожидания (с)", "driveName": "Название диска", - "drivePath": "Путь подъезда", + "drivePath": "Путь к диску", "ignoreCertificate": "Игнорировать сертификат", "ignoreCertificateDesc": "Разрешите подключения к хостам с самоподписанными сертификатами.", "forceLossless": "Сила без потерь", @@ -1294,7 +1359,7 @@ "swapRedBlueDesc": "Поменяйте местами красный и синий цветовые каналы (это исправит некоторые проблемы с цветопередачей).", "readOnly": "Только для чтения", "readOnlyDesc": "Просматривайте удаленный экран, не отправляя никаких входных данных.", - "telnetPort": "Порт Телнет", + "telnetPort": "Порт Telnet", "terminalType": "Тип терминала", "fontName": "Название шрифта", "fontSize": "Размер шрифта", @@ -1302,28 +1367,10 @@ "backspaceKey": "Клавиша Backspace", "saveHostFirst": "Сначала сохраните хост.", "sharingOptionsAfterSave": "Параметры доступа становятся доступны после сохранения хоста.", - "sharingLoadError": "Не удалось загрузить данные для обмена. Проверьте подключение и повторите попытку.", - "shareHostSection": "Совместный хостинг", - "shareWithUser": "Поделиться с пользователем", - "shareWithRole": "Поделиться с помощью роли", - "selectUser": "Выберите пользователя", - "selectRole": "Выберите роль", - "selectUserOption": "Выберите пользователя...", - "selectRoleOption": "Выберите роль...", "permissionLevel": "Уровень доступа", - "expiresInHours": "Срок действия истекает через (часы)", - "noExpiryPlaceholder": "Оставьте пустым, чтобы не истек срок действия.", - "shareBtn": "Делиться", - "currentAccess": "Текущий доступ", "typeHeader": "Тип", "targetHeader": "Цель", "permissionHeader": "Разрешение", - "grantedByHeader": "Утверждено", - "expiresHeader": "Срок действия истекает", - "noAccessEntries": "Пока нет записей с правами доступа.", - "expiredLabel": "Истекший", - "neverLabel": "Никогда", - "revokeBtn": "Отменить", "cancelBtn": "Отмена", "savingBtn": "Сохранение...", "updateHostBtn": "Обновить хост", @@ -1347,10 +1394,10 @@ "tmuxMonitorDesc": "Отслеживайте сессии tmux на всех ваших хостах.", "recentActivity": "Последние события", "serversAndHosts": "Серверы и хосты", - "noHostsFound": "Соответствующие хосты \"{{search}} не найдены", + "noHostsFound": "Соответствующие хосты \"{{search}}\" не найдены", "links": "Ссылки", "navigate": "Навигация", - "select": "Выбирать", + "select": "Выбрать", "toggleWith": "Переключить с помощью" }, "splitScreen": { @@ -1365,17 +1412,17 @@ "connected": "Подключено", "disconnected": "Отключено", "closeTab": "Закрыть вкладку", - "closeConnection": "Тесная связь", - "forgetTab": "Забывать", - "removeBackground": "Удалять", - "reconnect": "Переподключитесь", - "reopenTab": "Возобновить работу", + "closeConnection": "Закрыть соединение", + "forgetTab": "Забыть", + "removeBackground": "Удалить", + "reconnect": "Переподключиться", + "reopenTab": "Открыть заново", "sectionOpen": "Открыть", "sectionBackground": "Фон", - "backgroundDesc": "После отключения сеанс связи длится 30 минут, после чего его можно повторно подключить.", - "persisted": "Сохранялся на заднем плане", + "backgroundDesc": "Сессии сохраняются в течение 30 минут после отключения и могут быть восстановлены.", + "persisted": "Сохранено в фоне", "expiresIn": "Срок действия истекает через {{duration}}", - "search": "Поиск связей...", + "search": "Поиск сессий...", "noSearchResults": "Поисковые запросы не соответствуют вашим запросам.", "rename": "Переименовать сессию" }, @@ -1386,7 +1433,7 @@ "failedToConnect": "Не удалось получить токен подключения.", "hostNotFound": "Хост не найден", "noHostSelected": "Хост не выбран", - "reconnect": "Переподключитесь", + "reconnect": "Переподключиться", "retry": "Повторить попытку", "guacdUnavailable": "Служба удаленного рабочего стола (guacd) недоступна. Убедитесь, что guacd запущена, доступна и правильно настроена в параметрах администратора.", "ctrlAltDel": "Ctrl+Alt+Del", @@ -1397,12 +1444,12 @@ "ctrl": "Ctrl", "alt": "Альт", "shift": "Сдвиг", - "win": "Победить", + "win": "Win", "stickyActive": "{{key}} (защелкнуто - нажмите, чтобы отпустить)", "stickyInactive": "{{key}} (нажмите, чтобы защелкнуть)", - "esc": "Побег", + "esc": "Esc", "tab": "Вкладка", - "home": "Дом", + "home": "Home", "end": "Конец", "pageUp": "Страница вверх", "pageDown": "Вниз страницы", @@ -1411,7 +1458,7 @@ "arrowLeft": "Стрелка влево", "arrowRight": "Стрелка вправо", "fnToggle": "Функциональные клавиши", - "reconnect": "Повторное подключение сессии", + "reconnect": "Переподключить сессию", "collapse": "Свернуть панель инструментов", "expand": "Развернуть панель инструментов", "dragHandle": "Перетащите для изменения положения." @@ -1419,27 +1466,27 @@ }, "terminal": { "connect": "Подключиться к хосту", - "clear": "Прозрачный", + "clear": "Очистить", "paste": "Вставить", - "reconnect": "Переподключитесь", + "reconnect": "Переподключиться", "connectionLost": "Соединение потеряно", "connected": "Подключено", "clipboardWriteFailed": "Не удалось скопировать в буфер обмена. Убедитесь, что страница загружается по протоколу HTTPS или с локального сервера.", "clipboardReadFailed": "Не удалось прочитать данные из буфера обмена. Убедитесь, что права доступа к буферу обмена предоставлены.", "clipboardHttpWarning": "Для вставки требуется HTTPS. Используйте Ctrl+Shift+V или разместите Termix по протоколу HTTPS.", - "passwordPromptFillTitle": "Введите сохраненный пароль в это поле?", + "passwordPromptFillTitle": "Ввести сохраненный пароль в это поле?", "unknownError": "Произошла неизвестная ошибка.", "websocketError": "Ошибка подключения WebSocket", "connecting": "Подключение...", "noHostSelected": "Хост не выбран", - "reconnecting": "Восстанавливаем соединение... ({{attempt}}/{{max}})", - "reconnected": "Подключение успешно восстановлено.", + "reconnecting": "Переподключение... ({{attempt}}/{{max}})", + "reconnected": "Переподключение выполнено успешно", "tmuxSessionCreated": "Создана сессия tmux: {{name}}", "tmuxSessionAttached": "Прикреплена сессия tmux: {{name}}", "tmuxUnavailable": "tmux не установлен на удаленном хосте, поэтому используется стандартная оболочка.", "tmuxSessionPickerTitle": "Сессии tmux", "tmuxSessionPickerDesc": "На этом хосте обнаружены существующие сессии tmux. Выберите одну для повторного подключения или создания новой сессии.", - "tmuxWindows": "Windows", + "tmuxWindows": "Окна", "tmuxWindowCount": "{{count}} окно", "tmuxAttached": "Прикрепленные клиенты", "tmuxAttachedCount": "{{count}} прикреплено", @@ -1452,16 +1499,16 @@ "tmuxCopyHint": "Отрегулируйте выделение и нажмите Enter, чтобы скопировать в буфер обмена.", "tmuxDetach": "Отключитесь от сессии tmux.", "tmuxDetached": "Отключение от сессии tmux", - "maxReconnectAttemptsReached": "Достигнуто максимальное количество попыток переподключения.", - "closeTab": "Закрывать", + "maxReconnectAttemptsReached": "Достигнуто максимальное количество попыток переподключения", + "closeTab": "Закрыть", "connectionTimeout": "Таймаут соединения", "terminalTitle": "Терминал - {{host}}", "terminalWithPath": "Терминал - {{host}}:{{path}}", "runTitle": "Запуск {{command}} - {{host}}", "totpRequired": "Требуется двухфакторная аутентификация.", "totpCodeLabel": "Проверочный код", - "totpVerify": "Проверять", - "warpgateAuthRequired": "Требуется аутентификация варп-врат.", + "totpVerify": "Проверить", + "warpgateAuthRequired": "Требуется аутентификация Warpgate", "warpgateSecurityKey": "Ключ безопасности", "warpgateAuthUrl": "URL-адрес аутентификации", "warpgateOpenBrowser": "Открыть в браузере", @@ -1478,10 +1525,10 @@ "vaultAuthDescription": "Открылось окно для входа в HashiCorp Vault. Завершите вход; это соединение будет продолжено автоматически.", "vaultAuthFailed": "Аутентификация хранилища не удалась. Пожалуйста, попробуйте еще раз.", "vaultReopen": "Снова откройте окно входа в систему.", - "sudoPasswordPopupTitle": "Введите пароль?", + "sudoPasswordPopupTitle": "Вставить пароль?", "linkDialogTitle": "Открыть ссылку", "linkDialogOpen": "Открыть", - "linkDialogCopy": "Копия", + "linkDialogCopy": "Скопировать", "websocketAbnormalClose": "Соединение неожиданно разорвано. Это может быть связано с проблемами обратного прокси-сервера или конфигурации SSL. Пожалуйста, проверьте журналы сервера.", "connectionLogTitle": "Журнал подключений", "connectionLogCopy": "Скопировать журналы в буфер обмена", @@ -1491,7 +1538,7 @@ "connectionLogCopyFailed": "Не удалось скопировать логи в буфер обмена.", "connectionRejected": "Соединение отклонено сервером. Пожалуйста, проверьте настройки аутентификации и сети.", "hostKeyRejected": "Проверка ключа хоста SSH отклонена. Соединение разорвано.", - "sessionTakenOver": "Сессия была открыта в другой вкладке. Повторное подключение...", + "sessionTakenOver": "Сессия открыта в другой вкладке. Переподключение...", "split": { "splitTab": "Разделить вкладку", "addToSplit": "Добавить в раздел", @@ -1511,14 +1558,14 @@ "extractFailed": "Извлечение не удалось.", "compressFile": "Сжать файл", "compressFiles": "Сжать файлы", - "compressFilesDesc": "Сжать {{count}} элементов в архив", + "compressFilesDesc": "Сжать {{count}} объектов в архив", "archiveName": "Название архива", "enterArchiveName": "Введите название архива...", "compressionFormat": "Формат сжатия", "selectedFiles": "Выбранные файлы", "andMoreFiles": "и {{count}} ещё...", - "compress": "Компресс", - "compressingFiles": "Сжатие {{count}} элементов в {{name}}...", + "compress": "Сжать", + "compressingFiles": "Сжатие {{count}} объектов в {{name}}...", "filesCompressedSuccessfully": "{{name}} успешно создано", "compressFailed": "Сжатие не удалось", "edit": "Редактировать", @@ -1526,7 +1573,7 @@ "previous": "Предыдущий", "next": "Следующий", "pageXOfY": "Страница {{current}} из {{total}}", - "zoomOut": "Отдалиться", + "zoomOut": "Уменьшить масштаб", "zoomIn": "Увеличить масштаб", "newFile": "Новый файл", "newFolder": "Новая папка", @@ -1541,11 +1588,11 @@ "failedToDownloadFile": "Не удалось загрузить файл.", "fileCreatedSuccessfully": "Файл \"{{name}}\" успешно создан", "folderCreatedSuccessfully": "Папка \"{{name}}\" успешно создана", - "failedToCreateItem": "Не удалось создать элемент", + "failedToCreateItem": "Не удалось создать объект", "operationFailed": "Операция {{operation}} завершилась неудачей для {{name}}: {{error}}", "failedToResolveSymlink": "Не удалось разрешить символическую ссылку.", - "itemsDeletedSuccessfully": "{{count}} элементы успешно удалены", - "failedToDeleteItems": "Не удалось удалить элементы.", + "itemsDeletedSuccessfully": "{{count}} объекты успешно удалены", + "failedToDeleteItems": "Не удалось удалить объект", "sudoPasswordRequired": "Требуется пароль администратора.", "enterSudoPassword": "Введите пароль sudo для продолжения операции.", "sudoPassword": "пароль Sudo", @@ -1561,18 +1608,18 @@ "failedToLoadDirectory": "Не удалось загрузить каталог.", "noSSHConnection": "SSH-соединение недоступно", "copy": "Копия", - "cut": "Резать", + "cut": "Вырезать", "paste": "Вставить", "copyPath": "Скопировать путь", "copyPaths": "Копировать пути", "delete": "Удалить", - "properties": "Характеристики", + "properties": "Свойства", "refresh": "Обновить", "downloadFiles": "Скачать {{count}} файлов в браузер", - "copyFiles": "Скопировать {{count}} элементов", - "cutFiles": "Вырезать {{count}} предметов", - "deleteFiles": "Удалить {{count}} элементов", - "filesCopiedToClipboard": "{{count}} элементы скопированы в буфер обмена", + "copyFiles": "Копировать {{count}} объектов", + "cutFiles": "Вырезать {{count}} объектов", + "deleteFiles": "Удалить {{count}} объектов", + "filesCopiedToClipboard": "{{count}} объекты скопированы в буфер обмена", "filesCutToClipboard": "{{count}} элементы, вырезанные в буфер обмена", "pathCopiedToClipboard": "Путь скопирован в буфер обмена.", "pathsCopiedToClipboard": "{{count}} пути скопированы в буфер обмена", @@ -1581,10 +1628,10 @@ "copyCurrentFolderLink": "Скопировать ссылку в текущую папку", "folderLinkCopied": "Ссылка на папку скопирована в буфер обмена.", "failedToCopyFolderLink": "Не удалось скопировать ссылку на папку.", - "movedItems": "Перемещено {{count}} элементов", - "failedToDeleteItem": "Не удалось удалить элемент", + "movedItems": "Перемещено {{count}} объектов", + "failedToDeleteItem": "Не удалось удалить объект", "itemRenamedSuccessfully": "{{type}} успешно переименовано", - "failedToRenameItem": "Не удалось переименовать элемент.", + "failedToRenameItem": "Не удалось переименовать объект", "download": "Скачать", "openExternalEditor": "Открыть извне", "chooseExternalEditor": "Выберите внешний редактор", @@ -1595,7 +1642,7 @@ "failedToSelectExternalEditor": "Не удалось выбрать внешнего редактора.", "permissions": "Разрешения", "size": "Размер", - "modified": "Модифицированный", + "modified": "Изменён", "path": "Путь", "confirmDelete": "Вы уверены, что хотите удалить {{name}}?", "permissionDenied": "Доступ запрещен", @@ -1603,23 +1650,23 @@ "fileSavedSuccessfully": "Файл успешно сохранен.", "failedToSaveFile": "Не удалось сохранить файл.", "confirmDeleteSingleItem": "Вы уверены, что хотите навсегда удалить \"{{name}}\"?", - "confirmDeleteMultipleItems": "Вы уверены, что хотите навсегда удалить {{count}} элементов?", - "confirmDeleteMultipleItemsWithFolders": "Вы уверены, что хотите навсегда удалить {{count}} элементов? Это включает папки и их содержимое.", + "confirmDeleteMultipleItems": "Вы уверены, что хотите навсегда удалить {{count}} объектов?", + "confirmDeleteMultipleItemsWithFolders": "Вы уверены, что хотите навсегда удалить {{count}} объектов? Это включает папки и их содержимое.", "confirmDeleteFolder": "Вы уверены, что хотите навсегда удалить папку \"{{name}}\" и всё её содержимое?", - "permanentDeleteWarning": "Это действие необратимо. Указанный(е) элемент(ы) будут безвозвратно удалены с сервера.", - "recent": "Недавний", + "permanentDeleteWarning": "Это действие необратимо. Указанный(е) объект(ы) будут безвозвратно удалены с сервера.", + "recent": "Недавние", "pinned": "Закреплено", "folderShortcuts": "Ярлыки папок", "failedToReconnectSSH": "Не удалось повторно подключиться к SSH-сессии.", "openTerminalHere": "Откройте терминал здесь", - "run": "Бегать", + "run": "Запустить", "openTerminalInFolder": "Откройте Терминал в этой папке", "openTerminalInFileLocation": "Откройте терминал по адресу расположения файла.", - "runningFile": "Бег - {{file}}", + "runningFile": "Выполняется - {{file}}", "onlyRunExecutableFiles": "Можно запускать только исполняемые файлы.", "directories": "Справочники", - "removedFromRecentFiles": "Удалён символ \"{{name}}\" из последних файлов", - "removeFailed": "Удалить неудавшийся", + "removedFromRecentFiles": "\"{{name}}\" удален из недавних файлов", + "removeFailed": "Ошибка удаления", "unpinnedSuccessfully": "Откреплено \"{{name}}\" успешно", "unpinFailed": "Открепление не удалось", "removedShortcut": "Удалён ярлык \"{{name}}\"", @@ -1630,7 +1677,7 @@ "clearAllRecentFiles": "Удалите все недавно использованные файлы.", "unpinFile": "Открепить файл", "removeShortcut": "Удалить ярлык", - "pinFile": "Закрепленный файл", + "pinFile": "Закрепить файл", "addToShortcuts": "Добавить в ярлыки", "pasteFailed": "Вставка не удалась", "noUndoableActions": "Нет необратимых действий", @@ -1644,22 +1691,22 @@ "undoTypeNotSupported": "Неподдерживаемый тип операции отмены", "undoOperationFailed": "Операция отмены не удалась.", "unknownError": "Неизвестная ошибка", - "confirm": "Подтверждать", + "confirm": "Подтвердить", "find": "Находить...", - "replace": "Заменять", + "replace": "Заменить", "downloadInstead": "Скачать вместо", "keyboardShortcuts": "Клавиатурные сочетания", "searchAndReplace": "Поиск и замена", "editing": "Редактирование", "search": "Поиск", - "findNext": "Найти Далее", + "findNext": "Найти далее", "findPrevious": "Найти предыдущий", - "save": "Сохранять", + "save": "Сохранить", "selectAll": "Выбрать все", "undo": "Отменить", - "redo": "Переделать", - "moveLineUp": "Переместить состав", - "moveLineDown": "Сдвинуть линию вниз", + "redo": "Повторить", + "moveLineUp": "Переместить строку вверх", + "moveLineDown": "Переместить строку вниз", "toggleComment": "Скрыть/Показать комментарий", "autoComplete": "Автозаполнение", "imageLoadError": "Не удалось загрузить изображение", @@ -1679,12 +1726,12 @@ "comparingFiles": "Сравнение файлов: {{file1}} и {{file2}}", "dragFailed": "Операция перетаскивания не удалась.", "filePinnedSuccessfully": "Файл \"{{name}}\" успешно закреплен", - "pinFileFailed": "Не удалось закрепить файл.", + "pinFileFailed": "Не удалось закрепить файл", "fileUnpinnedSuccessfully": "Файл \"{{name}}\" успешно откреплен", "unpinFileFailed": "Не удалось открепить файл.", "shortcutAddedSuccessfully": "Ярлык папки \"{{name}}\" успешно добавлен", "addShortcutFailed": "Не удалось добавить ярлык.", - "operationCompletedSuccessfully": "{{operation}} {{count}} успешно добавлены элементы", + "operationCompletedSuccessfully": "{{operation}} {{count}} успешно добавлены объекты", "operationCompleted": "{{operation}} {{count}} предметов", "downloadFileSuccess": "Файл {{name}} успешно загружен", "downloadFileFailed": "Загрузка не удалась.", @@ -1693,18 +1740,18 @@ "dragOutsideToDownload": "Перетащите за пределы окна, чтобы загрузить ({{count}} файлов)", "newFolderDefault": "Новая папка", "newFileDefault": "NewFile.txt", - "successfullyMovedItems": "Успешно перемещено {{count}} элементов в {{target}}", - "move": "Двигаться", + "successfullyMovedItems": "Успешно перемещено {{count}} объектов в {{target}}", + "move": "Переместить", "searchInFile": "Поиск в файле (Ctrl+F)", "showKeyboardShortcuts": "Показать сочетания клавиш", "decreaseFontSize": "Уменьшить размер шрифта", "increaseFontSize": "Увеличьте размер шрифта", "startWritingMarkdown": "Начните создавать контент в формате Markdown...", "loadingFileComparison": "Загрузка сравнения файлов...", - "reload": "Перезагрузка", - "compare": "Сравнивать", + "reload": "Перезагрузить", + "compare": "Сравнить", "sideBySide": "Бок о бок", - "inline": "В соответствии", + "inline": "Встроенный", "fileComparison": "Сравнение файлов: {{file1}} против {{file2}}", "fileTooLarge": "Файл слишком большой: {{error}}", "sshConnectionFailed": "SSH-соединение не удалось. Пожалуйста, проверьте соединение с {{name}} ({{ip}}:{{port}})", @@ -1721,27 +1768,27 @@ "owner": "Владелец", "group": "Группа", "others": "Другие", - "read": "Читать", - "write": "Писать", - "execute": "Выполнять", + "read": "Чтение", + "write": "Запись", + "execute": "Выполнить", "permissionsChangedSuccessfully": "Права доступа успешно изменены.", "failedToChangePermissions": "Не удалось изменить права доступа.", "name": "Имя", "sortByName": "Имя", "sortByDate": "Дата изменения", "sortBySize": "Размер", - "ascending": "Восходящий", - "descending": "Спуск", - "root": "Корень", + "ascending": "По возрастанию", + "descending": "По убыванию", + "root": "Корневая", "new": "Новый", "sortBy": "Сортировать по", - "items": "Предметы", - "selected": "Избранные", + "items": "Объектов", + "selected": "Выбрано", "editor": "Редактор", "octal": "Восьмеричный", "storage": "Хранилище", "disk": "Диск", - "used": "Использовал", + "used": "Использовано", "of": "из", "toggleSidebar": "Переключить боковую панель", "cannotLoadPdf": "Не удаётся загрузить PDF-файл.", @@ -1752,8 +1799,8 @@ "transfer": { "copyToHost": "Скопировать на хост…", "moveToHost": "Переместить на хост…", - "copyItemsToHost": "Скопировать {{count}} элементов на хост…", - "moveItemsToHost": "Переместить {{count}} элементов на хост…", + "copyItemsToHost": "Скопировать {{count}} объектов на хост…", + "moveItemsToHost": "Переместить {{count}} объектов на хост…", "noHostsConnected": "Других хостов для файловых менеджеров нет в наличии.", "noHostsConnectedHint": "Добавьте еще один SSH-хост с включенным файловым менеджером в диспетчере хостов.", "selectDestinationHost": "Выберите целевой хост", @@ -1764,21 +1811,21 @@ "browseFolders": "Просмотрите папки назначения", "browseDestination": "Просмотрите или введите путь", "confirmCopy": "Копия", - "confirmMove": "Двигаться", + "confirmMove": "Переместить", "transferring": "Передача…", "compressing": "Сжатие…", "extracting": "Извлечение…", - "transferringItems": "Передача {{current}} элементов {{total}}…", + "transferringItems": "Передача {{current}} объектов {{total}}…", "transferSuccess": "Передача завершена", - "transferError": "Перевод не удался", + "transferError": "Передача не удалась", "transferPartial": "Передача завершена с ошибками {{count}}", "transferPartialHint": "Не удалось передать: {{paths}}", - "itemsSummary": "{{count}} предметов", - "destMustBeDirectory": "В качестве пункта назначения должен быть каталог для передачи нескольких элементов.", + "itemsSummary": "{{count}} объекты", + "destMustBeDirectory": "В качестве пункта назначения должен быть каталог для передачи нескольких объектов.", "selectThisFolder": "Выберите эту папку", "browsePathWillBeCreated": "Эта папка пока не существует. Она будет создана при начале передачи.", "browsePathError": "Не удалось открыть этот путь на целевом хосте.", - "goUp": "Поднимитесь", + "goUp": "Вверх", "copyFolderToHost": "Скопируйте папку на хост…", "moveFolderToHost": "Переместить папку на хост…", "hostReady": "Готовый", @@ -1786,7 +1833,7 @@ "hostDisconnected": "Не подключено", "hostAuthRequired": "Требуется аутентификация — сначала откройте файловый менеджер на этом хосте.", "hostConnectionFailed": "Соединение не удалось.", - "metricsTitle": "Сроки трансферов", + "metricsTitle": "Время передачи", "metricsPrepare": "Подготовьте пункт назначения: {{duration}}", "metricsCompress": "Сжатие по источнику: {{duration}}", "metricsHopSourceRead": "Источник → сервер: {{throughput}}", @@ -1800,29 +1847,29 @@ "progressExtracting": "Извлечение в целевом месте…", "progressTransferring": "Передача данных…", "progressReconnecting": "Воссоединение…", - "parallelSegmentsLabel": "Параллельные полосы для пересадки", + "parallelSegmentsLabel": "Параллельные каналы передачи", "parallelSegmentsOption": "{{count}} полосы", "parallelSegmentsHint": "Большие файлы разбиваются на фрагменты по 256 МБ. Для увеличения общей пропускной способности используются несколько каналов связи (например, запуск нескольких передач одновременно).", "progressTotalSpeed": "{{speed}} всего ({{lanes}} полос)", "progressTransferringItems": "Передача файлов ({{current}} из {{total}})…", "progressBytes": "{{transferred}} / {{total}}", - "progressItems": "{{current}} / {{total}} файлы", + "progressItems": "{{current}} / {{total}} файлов", "sourceNotDeletedPartial": "Исходные файлы сохранены (частичная передача).", "jumpHostLimitation": "Оба хоста должны быть доступны с сервера Termix. Прямая маршрутизация между хостами не поддерживается.", "cancel": "Отмена", "methodLabel": "Метод передачи", "methodAuto": "Авто", - "methodTar": "архив Tar", + "methodTar": "Tar-архив", "methodItemSftp": "SFTP для каждого файла", "methodAutoHint": "Выбор способа передачи данных (tar или SFTP для каждого файла) зависит от количества файлов, их размера и возможности сжатия. Для отдельных файлов всегда используется потоковая передача SFTP.", "methodTarHint": "Сжатие на источнике, передача одного архива, распаковка в пункте назначения. Требуется tar на обоих Unix-хостах.", - "methodItemSftpHint": "Передавайте каждый файл по отдельности по протоколу SFTP. Работает на всех хостах, включая Windows.", + "methodItemSftpHint": "Передавать каждый файл отдельно через SFTP. Работает на всех хостах, включая Windows.", "methodPreviewLoading": "Метод расчета переноса…", "methodPreviewError": "Не удалось предварительно просмотреть способ передачи. Сервер все равно выберет способ передачи при запуске.", "methodPreviewWillUseTar": "Будет использоваться: архив Tar", "methodPreviewWillUseItemSftp": "Будет использоваться: SFTP для каждого файла.", "methodPreviewScanSummary": "{{fileCount}} файлов, {{totalSize}} всего (просканировано на исходном хосте).", - "methodItemSftpLimitation": "Каждый файл копируется по одному и тому же SFTP-потоку, один за другим. Прогресс суммируется по всем файлам, поэтому индикатор выполнения движется медленно при работе с большими файлами.", + "methodItemSftpLimitation": "Каждый файл передается отдельно через тот же поток SFTP, что и при копировании одного файла. Прогресс рассчитывается для всех файлов вместе, поэтому при больших файлах индикатор может двигаться медленно.", "methodReason": { "user_item_sftp": "Вы выбрали SFTP для каждого файла.", "user_tar": "Вы выбрали архив tar.", @@ -1836,7 +1883,7 @@ }, "progressCancel": "Отмена", "progressCancelling": "Отмена…", - "progressStalled": "Застрял", + "progressStalled": "Зависло", "resumedHint": "Восстановлено соединение с активной передачей данных, начатой в другом окне.", "transferCancelled": "Трансфер отменен", "transferCancelledCopyHint": "Неполные файлы могут остаться на целевом устройстве.", @@ -1858,11 +1905,11 @@ "connecting": "Подключение...", "error": "Ошибка", "canceling": "Отмена...", - "connect": "Соединять", + "connect": "Подключиться", "disconnect": "Отключить", "cancel": "Отмена", "port": "Порт", - "localPort": "Местный порт", + "localPort": "Локальный порт", "remotePort": "Удаленный порт", "currentHostPort": "Текущий порт хоста", "endpointPort": "Порт конечной точки", @@ -1891,50 +1938,50 @@ "localSaveError": "Не удалось сохранить локальные клиентские туннели.", "invalidBindIp": "Локальный IP-адрес должен быть действительным IPv4-адресом.", "invalidLocalTargetIp": "Целевой IP-адрес должен быть действительным IPv4-адресом.", - "invalidLocalPort": "Местный порт должен находиться в диапазоне от 1 до 65535.", + "invalidLocalPort": "Локальный порт должен быть в диапазоне от 1 до 65535.", "invalidRemotePort": "Удаленный порт должен находиться в диапазоне от 1 до 65535.", "invalidLocalTargetPort": "Локальный целевой порт должен находиться в диапазоне от 1 до 65535.", "invalidEndpointPort": "Порт конечной точки должен находиться в диапазоне от 1 до 65535.", "duplicateAutoStartBind": "Только один клиентский туннель с автоматическим запуском может использовать {{bind}}.", "manualControlError": "Не удалось обновить состояние туннеля.", "active": "Активный", - "start": "Начинать", - "stop": "Останавливаться", + "start": "Запустить", + "stop": "Остановить", "test": "Тест", "type": "Туннельный тип", - "typeLocal": "Локальный (-Л)", - "typeRemote": "Пульт дистанционного управления (-R)", + "typeLocal": "Локальный (-L)", + "typeRemote": "Удалённый (-R)", "typeDynamic": "Динамический (-D)", "typeServerLocalDesc": "Текущий хост — конечная точка.", "typeServerRemoteDesc": "Обратная связь с текущим хостом.", "typeClientLocalDesc": "Локальный компьютер — конечная точка.", "typeClientRemoteDesc": "Конечная точка подключения — локальный компьютер.", - "typeClientDynamicDesc": "Носки на локальном компьютере.", + "typeClientDynamicDesc": "SOCKS на локальном компьютере.", "typeDynamicDesc": "Переадресация трафика SOCKS5 CONNECT через SSH.", "forwardDescriptionServerLocal": "Текущий хост {{sourcePort}} → конечная точка {{endpointPort}}.", "forwardDescriptionServerRemote": "Конечная точка {{endpointPort}} → текущий хост {{sourcePort}}.", "forwardDescriptionServerDynamic": "SOCKS на текущем хосте {{sourcePort}}.", "forwardDescriptionClientLocal": "Локальный {{sourcePort}} → удаленный {{endpointPort}}.", "forwardDescriptionClientRemote": "Удаленный {{sourcePort}} → локальный {{endpointPort}}.", - "forwardDescriptionClientDynamic": "НОСКИ в местном порту {{sourcePort}}.", + "forwardDescriptionClientDynamic": "SOCKS на локальном порту {{sourcePort}}.", "summaryClientLocal": "{{localHost}}:{{localPort}} → {{endpoint}}:{{remotePort}}", "summaryClientRemote": "{{endpoint}}:{{remotePort}} → {{localHost}}:{{localPort}}", - "summaryClientDynamic": "{{localHost}}:{{localPort}} → НОСКИ через {{endpoint}}", + "summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS через {{endpoint}}", "autoNameClientLocal": "Локальный {{localPort}} → {{endpoint}} {{remotePort}}", "autoNameClientRemote": "{{endpoint}} {{remotePort}} → локальный {{localPort}}", - "autoNameClientDynamic": "НОСКИ {{localPort}} через {{endpoint}}", + "autoNameClientDynamic": "SOCKS {{localPort}} через {{endpoint}}", "route": "Маршрут:", - "lastStarted": "Последний раз начал", + "lastStarted": "Последний запуск", "lastTested": "Последнее тестирование", "lastError": "Последняя ошибка", "maxRetries": "Максимальное количество повторных попыток", "maxRetriesDescription": "Максимальное количество попыток повтора.", "retryInterval": "Интервал повторной попытки (секунды)", "retryIntervalDescription": "Время ожидания между повторными попытками.", - "local": "Местный", + "local": "Локальный", "remote": "Удаленный", "destination": "Место назначения", - "host": "Хозяин", + "host": "Хост", "mode": "Режим", "noHostSelected": "Хост не выбран", "working": "Работающий..." @@ -1944,7 +1991,7 @@ "dragToResize": "Перетащите для изменения размера.", "changeWidth": "Изменить ширину", "removeCard": "Удалить {{label}}", - "addCard": "Добавлять", + "addCard": "Добавить", "columns": "Колонки", "empty": "Карточки не требуются. Используйте кнопку «Добавить» ниже, чтобы разместить карточки." }, @@ -1955,8 +2002,8 @@ "network": "Сеть", "uptime": "Время безотказной работы", "processes": "Процессы", - "available": "Доступный", - "free": "Бесплатно", + "available": "Доступно", + "free": "Свободно", "connecting": "Подключение...", "connectionFailed": "Не удалось подключиться к серверу.", "naCpus": "N/A CPU(s)", @@ -1973,7 +2020,7 @@ "totpFailed": "Проверка TOTP не удалась.", "noneAuthNotSupported": "Host Metrics не поддерживает тип аутентификации 'none'.", "noHostSelected": "Хост не выбран", - "load": "Нагрузка", + "load": "Загрузить", "systemInfo": "Информация о системе", "hostname": "Имя хоста", "operatingSystem": "Операционная система", @@ -2002,56 +2049,56 @@ }, "firewall": { "title": "Брандмауэр", - "inactive": "Неактивный", + "inactive": "Неактивен", "policy": "Политика", "rules": "правила", "noRules": "В этой цепочке нет правил.", "noData": "Данные брандмауэра отсутствуют.", "action": "Действие", - "protocol": "Прото", + "protocol": "Протокол", "port": "Порт", "source": "Источник", "anywhere": "В любом месте", - "chains": "цепи" + "chains": "цепочки" }, "loadAvg": "Средняя загрузка", - "swap": "Менять", + "swap": "Swap", "architecture": "Архитектура", "refresh": "Обновить", "retry": "Повторить попытку", "customize": "Настроить макет", - "reset": "Перезагрузить", - "tabLive": "Жить", + "reset": "Сбросить", + "tabLive": "В реальном времени", "editModeInstructions": "Перетаскивайте карточки, чтобы изменить их порядок, перетаскивайте нижний край, чтобы изменить размер, и используйте кнопку ширины, чтобы изменить размер карточки. Добавляйте или удаляйте карточки ниже.", "managers": { - "services": "Услуги", - "processInspector": "Инспектор технологического процесса", + "services": "Сервисы", + "processInspector": "Инспектор процессов", "logViewer": "Просмотрщик журналов", "cron": "Задания Cron", "packages": "Пакеты", "ssl": "SSL-сертификаты", "firewall": "Брандмауэр", "users": "Пользователи и разрешения", - "healthCheck": "Медицинские осмотры", - "diskBreakdown": "Поломка диска", + "healthCheck": "Проверка состояния", + "diskBreakdown": "Разбивка диска", "systemdTimers": "Таймеры", "topMemory": "Сверху по памяти", "noData": "Нет данных", "sudoHint": "Установите пароль sudo для этого хоста в редакторе хостов, чтобы разрешить выполнение привилегированных действий.", "filter": "Фильтр...", - "start": "Начинать", - "stop": "Останавливаться", - "restart": "Перезапуск", + "start": "Запустить", + "stop": "Остановить", + "restart": "Перезапустить", "actionDone": "{{name}} обновлено", "actionFailed": "Действие не удалось.", "signalSent": "Сигнал отправлен ПИД-регулятору {{pid}}", "killHint": "Щелчок мышью: завершить процесс (SIGTERM). Щелчок правой кнопкой мыши: принудительно завершить процесс (SIGKILL).", "working": "Работающий...", - "update": "Обновлять", + "update": "Обновить", "upgradeAll": "Обновить все", "allUpToDate": "Всё актуально.", - "save": "Сохранять", - "command": "Командование", + "save": "Сохранить", + "command": "Команда", "enabled": "Включено", "cronSaved": "Обновление Crontab", "clients": "Клиенты", @@ -2059,19 +2106,19 @@ "renew": "Обновить", "noAcmeClient": "На этом хосте не найден клиент ACME (certbot или acme.sh).", "addInputRule": "Добавить правило ввода", - "firewallWarning": "Изменения вступают в силу только во время выполнения программы, пока не будут сохранены. Будьте осторожны, чтобы не заблокировать себе доступ.", + "firewallWarning": "Изменения действуют только во время работы, пока не будут сохранены. Будьте осторожны, чтобы не потерять доступ.", "ruleApplied": "Применено правило", "invalidPort": "Введите действительный порт (1-65535)", "newUsername": "Новое имя пользователя", - "addUser": "Добавлять", + "addUser": "Добавить", "deleteUser": "Удалить пользователя", "noHealthChecks": "Пока не настроены никакие проверки состояния здоровья.", - "follow": "Следовать", + "follow": "Отслеживать", "noLogData": "В логах ничего не отображается.", "tree": "Дерево", "enableDisable": "Включать/отключать при загрузке", - "grantSudo": "Грант судо", - "revokeSudo": "Отменить sudo", + "grantSudo": "Предоставить права sudo", + "revokeSudo": "Отозвать sudo", "sslIssueCert": "Выдать сертификат", "sslExpired": "Истекший", "sslInDays": "в {{days}}д", @@ -2086,10 +2133,10 @@ "sslRevoke": "Отозвать сертификат", "sslRevoked": "Сертификат аннулирован", "sslRevokeConfirm": "Отозвать и удалить сертификат \"{{name}}\"? Это невозможно отменить.", - "healthRun": "Бегать", + "healthRun": "Запустить", "healthName": "Имя", "healthTarget": "Хост / адрес", - "healthAddCheck": "Добавить галочку", + "healthAddCheck": "Добавить проверку", "healthSaved": "Сохранены медицинские осмотры", "healthMissingFields": "Для каждой проверки необходимо указать название и цель.", "logFile": "Файл", @@ -2097,17 +2144,17 @@ "logCustomPath": "Укажите путь в каталоге /var/log (необязательно)", "logGrep": "Фильтрующие линии...", "firewallPersist": "Сохранять правила", - "firewallPersisted": "Правила брандмауэра сохранялись.", + "firewallPersisted": "Правила брандмауэра сохранены", "wireguard": "WireGuard", - "tailscale": "Хвостовая чешуя", + "tailscale": "Tailscale", "wgNotInstalled": "На этом хосте не установлен WireGuard.", "wgNoInterfaces": "Интерфейсы WireGuard не настроены", - "wgInterfaceUp": "Воспитывать", - "wgInterfaceDown": "Свергнуть", - "wgBringingUp": "Выращивая {{name}}...", - "wgBringingDown": "Свержение {{name}}...", + "wgInterfaceUp": "Поднять", + "wgInterfaceDown": "Отключить", + "wgBringingUp": "Включение {{name}}...", + "wgBringingDown": "Отключение {{name}}...", "wgInterfaceUpDone": "{{name}} включен", - "wgInterfaceDownDone": "{{name}} находится внизу", + "wgInterfaceDownDone": "{{name}} отключен", "wgListenPort": "Порт", "wgPublicKey": "Открытый ключ", "wgEndpoint": "Конечная точка", @@ -2116,16 +2163,16 @@ "wgHandshakeNever": "Никогда", "wgTransfer": "Передача", "tsNotInstalled": "На этом хосте не установлена программа Tailscale.", - "tsRunning": "Бег", + "tsRunning": "Выполняется", "tsStopped": "Остановлено", - "tsEnable": "Соединять", + "tsEnable": "Подключиться", "tsDisable": "Отключить", "tsEnabling": "Подключение к Tailscale...", "tsDisabling": "Отключение от Tailscale...", - "tsEnabled": "Хвостовая чешуя подключена", - "tsDisabled": "Хвостовая чешуя отсоединена", + "tsEnabled": "Tailscale подключён", + "tsDisabled": "Tailscale отключён", "tsIPs": "IP-блоки Tailscale", - "tsPeers": "Коллеги", + "tsPeers": "Пиры", "tsOnline": "Онлайн", "tsOffline": "Офлайн", "tsExitNode": "Выходной узел", @@ -2138,7 +2185,7 @@ "loginTitle": "Добро пожаловать", "registerTitle": "Зарегистрироваться", "forgotPassword": "Забыли пароль?", - "rememberMe": "Функция запоминания устройства на 30 дней (включая TOTP)", + "rememberMe": "Запомнить устройство на 30 дней (включая TOTP)", "noAccount": "У вас нет аккаунта?", "hasAccount": "У вас уже есть аккаунт?", "twoFactorAuth": "Двухфакторная аутентификация", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Вход по паролю отключен. Используйте пароль или внешний источник аутентификации.", "signInWithPasskey": "Войдите с помощью пароля.", "passkeyLoginFailed": "Не удалось войти с помощью пароля.", - "attemptsRemaining": "{{count}} попыток осталось" + "attemptsRemaining": "{{count}} попыток осталось", + "confirmResetDataWipe": "Эта учетная запись не использовалась с момента обновления шифрования, поэтому ее сохраненные данные невозможно восстановить без старого пароля. Сброс пароля приведет к безвозвратному удалению ее хостов, учетных данных и фрагментов кода. Продолжить?" }, "hostKey": { "verifyNewHost": "Проверьте ключ хоста SSH.", @@ -2198,9 +2246,9 @@ "firstConnectionTitle": "Первое подключение к этому хосту.", "firstConnectionDescription": "Подлинность этого хоста установить невозможно. Убедитесь, что отпечаток соответствует вашим ожиданиям.", "keyChangedDescription": "Ключ хоста для этого сервера изменился с момента вашего последнего подключения. Это может указывать на проблему с безопасностью.", - "previousKey": "Предыдущая клавиша", - "newFingerprint": "Новый отпечаток пальца", - "fingerprint": "Отпечаток пальца", + "previousKey": "Предыдущий ключ", + "newFingerprint": "Новый отпечаток", + "fingerprint": "Отпечаток", "verifyInstructions": "Если вы доверяете этому хосту, нажмите «Принять», чтобы продолжить и сохранить этот отпечаток для будущих подключений.", "securityWarning": "Предупреждение о безопасности", "acceptAndContinue": "Принять и продолжить", @@ -2251,7 +2299,7 @@ "c2sPresetToLoad": "Предварительные настройки для загрузки", "c2sNoPresetSelected": "Предварительно заданные параметры не выбраны", "c2sNoPresets": "Предустановки не сохранены", - "c2sLoadPreset": "Нагрузка", + "c2sLoadPreset": "Загрузить", "c2sCurrentLocalConfig": "{{count}} На этом рабочем столе настроены локальные клиентские туннели.", "c2sPresetSyncNote": "Предустановки представляют собой конкретные снимки состояния; загрузка одной из них заменяет локальный список клиентских туннелей этого настольного клиента.", "c2sPresetSaved": "Сохранены предустановленные параметры клиентского туннеля.", @@ -2277,7 +2325,7 @@ "title": "Панель управления", "loading": "Загрузка панели управления...", "github": "GitHub", - "support": "Поддерживать", + "support": "Поддержка", "discord": "Discord", "docs": "Документы", "donate": "Пожертвовать", @@ -2290,11 +2338,11 @@ "database": "База данных", "healthy": "Здоровый", "error": "Ошибка", - "totalHosts": "Total Hosts", + "totalHosts": "Всего хостов", "totalTunnels": "Всего туннелей", "totalCredentials": "Полный набор учетных данных", "recentActivity": "Последние события", - "reset": "Перезагрузить", + "reset": "Сбросить", "loadingRecentActivity": "Загрузка последних событий...", "noRecentActivity": "Нет недавней активности", "quickActions": "Быстрые действия", @@ -2306,15 +2354,15 @@ "loadingServerStats": "Загрузка статистики сервера...", "noServerData": "Данные сервера отсутствуют.", "cpu": "Процессор", - "ram": "БАРАН", + "ram": "ОЗУ", "customizeLayout": "Настройка панели управления", "dashboardSettings": "Настройки панели управления", "enableDisableCards": "Включить/отключить карты", "resetLayout": "Сброс до значений по умолчанию", "serverOverviewCard": "Обзор сервера", "recentActivityCard": "Последние события", - "networkGraphCard": "Сетевой граф", - "networkGraph": "Сетевой граф", + "networkGraphCard": "Карта сети", + "networkGraph": "Карта сети", "quickActionsCard": "Быстрые действия", "serverStatsCard": "Статистика сервера", "panelMain": "Основной", @@ -2323,6 +2371,13 @@ "serviceLinks": "Ссылки на сервисы", "homepagePreview": "Предварительный просмотр главной страницы" }, + "donation": { + "title": "Вам нравится Termix?", + "body": "Termix — бесплатный проект с открытым исходным кодом, разработанный и поддерживаемый командой из двух человек в свободное время. Если он заменит коммерческий инструмент, за который вы бы платили, пожертвование поможет покрыть расходы на хостинг и продолжить разработку. На данный момент пожертвования принимаются только в криптовалюте.", + "milestones": "Пожертвования также помогают финансировать исследования и изучение того, что необходимо для разработки таких функций, как поддержка SAML и Kubernetes. Следите за ходом работ на странице пожертвований.", + "cta": "Пожертвовать", + "dismiss": "Возможно, позже" + }, "dashboardTab": { "stable": "СТАБИЛЬНЫЙ", "hostsOnline": "Хосты онлайн", @@ -2338,17 +2393,17 @@ "checking": "ПРОВЕРКА", "onlineLower": "Онлайн", "nodes": "{{count}} узлов", - "add": "Добавлять:", + "add": "Добавить:", "commandPalette": "Палитра команд", - "done": "Сделанный", - "editModeInstructions": "Перетаскивайте карточки, чтобы изменить порядок · Перетаскивайте разделитель столбцов, чтобы изменить размер столбцов · Перетаскивайте нижний край карточки, чтобы изменить ее высоту · Удалите карточку из корзины", + "done": "Готово", + "editModeInstructions": "Перетаскивайте карточки, чтобы изменить их порядок · Перетаскивайте разделитель столбцов, чтобы изменить их ширину · Перетаскивайте нижний край карточки, чтобы изменить её высоту · Перетащите в корзину, чтобы удалить", "empty": "Пустой", - "clear": "Прозрачный", + "clear": "Очистить", "serviceLinksTitle": "Ссылки на сервисы", "serviceLinksEmpty": "Ссылки на сервисы пока отсутствуют.", - "serviceLinksAddLabel": "Этикетка", + "serviceLinksAddLabel": "Метка", "serviceLinksAddUrl": "URL", - "serviceLinksAdd": "Добавлять", + "serviceLinksAdd": "Добавить", "serviceLinksLabelPlaceholder": "Моя услуга", "serviceLinksUrlPlaceholder": "http://192.168.1.10:8080", "serviceLinksInvalidUrl": "Введите действительный веб-адрес", @@ -2377,10 +2432,10 @@ "addGroup": "Добавить группу", "addLink": "Добавить ссылку", "zoomIn": "Увеличить масштаб", - "zoomOut": "Отдалиться", + "zoomOut": "Уменьшить масштаб", "resetView": "Сбросить вид", "selectHost": "Выберите хост", - "chooseHost": "Выберите хостинг...", + "chooseHost": "Выберите хост...", "parentGroup": "Родительская группа", "noGroup": "Нет группы", "groupName": "Название группы", @@ -2392,13 +2447,13 @@ "addConnection": "Добавить соединение", "hostDetails": "Сведения о хосте", "removeFromGroup": "Удалить из группы", - "addHostHere": "Добавить хоста сюда", + "addHostHere": "Добавить хост сюда", "editGroup": "Редактировать группу", "delete": "Удалить", - "add": "Добавлять", - "create": "Создавать", - "move": "Двигаться", - "connect": "Соединять", + "add": "Добавить", + "create": "Создать", + "move": "Переместить", + "connect": "Подключиться", "createGroup": "Создать группу", "selectSourcePlaceholder": "Выберите источник...", "selectTargetPlaceholder": "Выберите цель...", @@ -2442,7 +2497,7 @@ "image": "Изображение", "ports": "Порты", "noPorts": "Нет портов", - "start": "Начинать", + "start": "Запустить", "confirmRemoveContainer": "Вы уверены, что хотите удалить контейнер '{{name}}'? Это действие необратимо.", "runningContainerWarning": "Внимание: Этот контейнер в данный момент запущен. Удаление контейнера сначала остановит основной контейнер.", "loadingContainers": "Загрузка контейнеров...", @@ -2453,7 +2508,7 @@ "filterLogs": "Фильтрация журналов...", "refresh": "Обновить", "download": "Скачать", - "clear": "Прозрачный", + "clear": "Очистить", "logsDownloaded": "Журналы успешно загружены.", "last50": "Последние 50", "last100": "Последние 100", @@ -2466,7 +2521,7 @@ "noContainersFoundHint": "На этом хосте контейнеры Docker недоступны.", "searchPlaceholder": "Обыск контейнеров...", "allStatuses": "Все статусы", - "stateRunning": "Бег", + "stateRunning": "Выполняется", "statePaused": "Приостановлено", "stateExited": "Вышел", "stateRestarting": "Перезапуск", @@ -2485,12 +2540,12 @@ "input": "Вход", "output": "Выход", "blockIo": "Блочный ввод-вывод", - "read": "Читать", - "write": "Писать", + "read": "Чтение", + "write": "Запись", "pids": "Идентификаторы процессов", "containerInformation": "Информация о контейнере", "name": "Имя", - "id": "ИДЕНТИФИКАТОР", + "id": "ID", "state": "Состояние", "containerMustBeRunning": "Для доступа к консоли контейнер должен быть запущен.", "verificationCodePrompt": "Введите проверочный код", @@ -2503,13 +2558,13 @@ "failedToConnect": "Не удалось подключиться к контейнеру.", "console": "Консоль", "selectShell": "Выберите оболочку", - "bash": "Баш", - "sh": "ш", - "ash": "пепел", - "connect": "Соединять", + "bash": "Bash", + "sh": "sh", + "ash": "ash", + "connect": "Подключиться", "disconnect": "Отключить", "notConnected": "Не подключено", - "clickToConnect": "Нажмите «Подключиться», чтобы начать сеанс командной строки.", + "clickToConnect": "Нажмите «Подключиться», чтобы начать работу в терминале", "connectingTo": "Подключение к {{containerName}}...", "containerNotFound": "Контейнер не найден", "backToList": "Вернуться к списку", @@ -2520,7 +2575,7 @@ }, "admin": { "sectionGeneral": "Общий", - "sectionOidc": "ОИДК", + "sectionOidc": "OIDC", "sectionSso": "Поставщики SSO", "ssoAddProvider": "Добавить поставщика", "ssoDocsLink": "Просмотреть документацию", @@ -2530,20 +2585,20 @@ "ssoProviderType": "Тип поставщика", "ssoDeleteProvider": "Удалить поставщика", "ssoDeleteConfirm": "Удалить этого поставщика услуг? Связанные пользователи не смогут войти в систему.", - "ssoTypeOidc": "ОИДК", + "ssoTypeOidc": "OIDC", "ssoTypeLdap": "LDAP", "ssoTypeGithub": "GitHub", "ssoTypeGoogle": "Google", "ssoEnabled": "Включено", - "ssoDisabled": "Неполноценный", + "ssoDisabled": "Отключено", "ssoSaveProvider": "Сохранить поставщика", "ssoTestConnection": "Проверить соединение", "ssoEditProvider": "Поставщик редактирования", "ldapHost": "Хост LDAP", "ldapPort": "Порт", "ldapUseTls": "Используйте TLS (LDAP).", - "ldapBindDn": "Bind DN", - "ldapBindPassword": "Привязать пароль", + "ldapBindDn": "DN привязки", + "ldapBindPassword": "Пароль привязки", "ldapUserSearchBase": "База поиска пользователей", "ldapUserSearchFilter": "Фильтр поиска пользователя", "ldapUsernameAttr": "Атрибут имени пользователя", @@ -2566,8 +2621,8 @@ "sslEmail": "Электронная почта", "sslEmailPlaceholder": "admin@example.com", "sslEmailDesc": "Для получения уведомлений от Let's Encrypt и управления учетной записью используйте электронный адрес.", - "sslChallengeType": "Тип задачи", - "sslChallengeTypeDesc": "Как доказать Let's Encrypt право собственности на домен.", + "sslChallengeType": "Тип проверки", + "sslChallengeTypeDesc": "Как подтвердить право владения доменом для Let's Encrypt.", "sslChallengeHttp": "HTTP (корневой каталог веб-сервера) — требуется доступ к порту 80 из интернета.", "sslChallengeDns": "DNS (Cloudflare) — требуется токен API Cloudflare.", "sslCloudflareToken": "API-токен Cloudflare", @@ -2614,7 +2669,7 @@ "oidcSilentLoginDefaultDesc": "Автоматическое перенаправление на страницу входа в OIDC при каждом посещении, без заполнения формы авторизации.", "allowPasswordReset": "Разрешить сброс пароля", "allowPasswordResetDesc": "Сброс кода через логи Docker", - "commandHistoryEnabled": "История командования", + "commandHistoryEnabled": "История команд", "commandHistoryEnabledDesc": "Разрешить запись истории команд. Если эта функция отключена, история не сохраняется независимо от настроек для каждого хоста.", "updateCommandHistoryFailed": "Не удалось обновить настройки истории команд.", "sessionTimeout": "Тайм-аут сессии", @@ -2625,12 +2680,12 @@ "metrics": "Метрики", "sec": "сек", "logLevel": "Уровень лога", - "enableGuacamole": "Включить гуакамоле", + "enableGuacamole": "Включить Guacamole", "enableGuacamoleDesc": "Удаленный рабочий стол RDP/VNC", "enableGuacamoleDocsLink": "Просмотреть документацию", "guacdUrl": "URL guacd", - "tailscaleApiKey": "Ключ API для масштабирования хвоста", - "tailscaleApiKeyDescription": "Используется для обнаружения устройств в редакторе хоста. Сгенерируйте ключ на tailscale.com/admin/settings/keys.", + "tailscaleApiKey": "Ключ API Tailscale", + "tailscaleApiKeyDescription": "Используется для обнаружения устройств в редакторе хостов. Создайте ключ на tailscale.com/admin/settings/keys.", "tailscaleApiKeyDocsLink": "Просмотреть документацию", "oidcDescription": "Настройте OpenID Connect для единого входа (SSO). Поля, отмеченные *, обязательны для заполнения.", "oidcDocsLink": "Просмотреть документацию", @@ -2647,19 +2702,19 @@ "oidcAllowedUsersDesc": "Один адрес электронной почты на строку. Оставьте поле пустым, чтобы разрешить все адреса.", "oidcAdminGroup": "Группа администраторов", "oidcAdminGroupDesc": "Пользователям в этой группе предоставляются права администратора. Оставьте поле пустым, чтобы отключить синхронизацию групп.", - "oidcGroupClaim": "Групповое заявление", + "oidcGroupClaim": "Поле группы", "oidcGroupClaimDesc": "Необязательный параметр. Путь к условию, содержащему группы пользователя. По умолчанию: группы, роли, затем группа. Используйте это для поставщиков с пользовательским условием (например, Zitadel).", "oidcCaCert": "Пользовательский сертификат центра сертификации", "oidcCaCertDesc": "Необязательно. Сертификат центра сертификации в формате PEM для поставщиков OIDC, использующих частный или самоподписанный центр сертификации. Оставьте поле пустым, чтобы использовать системное хранилище доверенных сертификатов.", - "removeOidc": "Удалять", + "removeOidc": "Удалить", "usersCount": "{{count}} пользователей", - "createUser": "Создавать", + "createUser": "Создать", "newRole": "Новая роль", "roleName": "Имя", "roleDisplayName": "Отображаемое имя", "roleDescription": "Описание", "rolesCount": "{{count}} роли", - "createRole": "Создавать", + "createRole": "Создать", "creating": "Создание...", "exportDatabase": "Экспорт базы данных", "exportDatabaseDesc": "Загрузите резервную копию всех хостов, учетных данных и настроек.", @@ -2669,7 +2724,7 @@ "importDatabaseDesc": "Восстановление из файла резервной копии .sqlite", "importDatabaseSelected": "Выбрано: {{name}}", "selectFile": "Выберите файл", - "changeFile": "Изменять", + "changeFile": "Изменить", "import": "Импорт", "importing": "Импорт...", "apiKeysCount": "{{count}} ключи", @@ -2684,14 +2739,14 @@ "apiKeyNoExpiry": "Срок действия не ограничен.", "revokedBadge": "ОТМЕНЕНО", "authTypeDual": "Двойная аутентификация", - "authTypeOidc": "ОИДК", - "authTypeLocal": "Местный", + "authTypeOidc": "OIDC", + "authTypeLocal": "Локальный", "adminStatusAdministrator": "Администратор", "adminStatusRegularUser": "Обычный пользователь", "adminBadge": "АДМИНИСТРАТОР", "systemBadge": "СИСТЕМА", "customBadge": "ОБЫЧАЙ", - "youBadge": "ТЫ", + "youBadge": "ВЫ", "sessionsActive": "{{count}} активный", "sessionActive": "Активно: {{time}}", "sessionExpires": "Exp: {{time}}", @@ -2709,7 +2764,7 @@ "userRoles": "Роли", "revokeAllUserSessions": "Отменить все сессии", "revokeAllUserSessionsDesc": "Принудительный повторный вход в систему на всех устройствах", - "revoke": "Отменить", + "revoke": "Отозвать", "deleteUserWarning": "Удаление этого пользователя будет безвозвратным.", "deleteUser": "Удалить {{username}}", "deleting": "Удаление...", @@ -2719,7 +2774,7 @@ "revokeKeySuccess": "Ключ \"{{name}}\" отозван", "revokeKeyFailed": "Не удалось отозвать ключ.", "copiedToClipboard": "Скопировано в буфер обмена", - "done": "Сделанный", + "done": "Готово", "createUserTitle": "Создать пользователя", "createUserDesc": "Создайте новую локальную учетную запись.", "createUserUsername": "Имя пользователя", @@ -2770,12 +2825,12 @@ "monitoringIntervalInvalid": "Недопустимые значения интервала", "monitoringSaved": "Сохранены настройки мониторинга.", "monitoringSaveFailed": "Не удалось сохранить настройки мониторинга.", - "metricsHistoryRetention": "Метрики История Сохранение", + "metricsHistoryRetention": "Сохранение истории метрик", "metricsHistoryRetentionRange": "от 1 до 90 дней", "days": "дни", - "guacamoleSaved": "Настройки гуакамоле сохранены.", - "guacamoleSaveFailed": "Не удалось сохранить настройки гуакамоле.", - "guacamoleUpdateFailed": "Не удалось обновить настройки гуакамоле.", + "guacamoleSaved": "Настройки Guacamole сохранены", + "guacamoleSaveFailed": "Не удалось сохранить настройки Guacamole", + "guacamoleUpdateFailed": "Не удалось обновить настройки Guacamole", "tailscaleSettingsSaved": "Сохранены настройки масштаба хвоста", "tailscaleSettingsSaveFailed": "Не удалось сохранить настройки масштаба хвоста.", "logLevelUpdateFailed": "Не удалось обновить уровень логирования.", @@ -2788,8 +2843,73 @@ "createUserSuccess": "Пользователь \"{{username}}\" создал", "createUserFailed": "Не удалось создать пользователя.", "updateAdminStatusFailed": "Не удалось обновить статус администратора.", - "allSessionsRevoked": "Все заседания отменены", + "allSessionsRevoked": "Все сеансы отозваны", "revokeSessionsFailed": "Не удалось отменить сессии.", + "manageUserData": "Управление пользовательскими данными", + "backToUsers": "Вернуться к пользователям", + "manageTabAccount": "Счет", + "manageTabHosts": "Хозяева", + "manageTabCredentials": "Реквизиты для входа", + "manageTabSnippets": "Фрагменты", + "manageTabSessions": "Сессии", + "manageTabDanger": "Опасность", + "manageEditorBack": "Вернуться к данным {{username}}", + "dataLockedBadge": "ЗАБЛОКИРОВАНО", + "dataLockedNotice": "Данные пользователя {{username}}остаются заблокированными до следующего входа в систему. До этого момента его хосты, учетные данные и фрагменты кода нельзя просмотреть или отредактировать.", + "resetPasswordTitle": "Сбросить пароль", + "resetPasswordOidcOnly": "Этот пользователь входит в систему через стороннего провайдера и не имеет пароля.", + "resetPasswordPlaceholder": "Новый пароль", + "resetPasswordBtn": "Перезагрузить", + "resetPasswordWorking": "Сброс...", + "resetPasswordSuccess": "Сброс пароля", + "resetPasswordSuccessWiped": "Пароль сброшен. Зашифрованные данные пользователя были удалены.", + "resetPasswordFailed": "Не удалось сбросить пароль", + "resetPasswordConfirmWipe": "{{username}} не входил в систему с момента обновления шифрования, поэтому его данные восстановить невозможно. Сброс настроек приведет к удалению его хостов, учетных данных и фрагментов кода. Продолжить?", + "totpSectionTitle": "Двухфакторная аутентификация", + "totpStatusEnabled": "Для этого пользователя включена функция TOTP.", + "totpStatusDisabled": "Для этого пользователя функция TOTP отключена.", + "disableTotp": "Запрещать", + "disableTotpConfirm": "Отключить двухфакторную аутентификацию для {{username}}? Они смогут войти в систему, используя только свой пароль.", + "totpDisabledSuccess": "TOTP отключен", + "totpDisableFailed": "Не удалось отключить TOTP.", + "manageApiKeys": "Ключи API", + "apiKeyNamePlaceholder": "Имя ключа", + "apiKeyCopyNotice": "Скопируйте этот ключ сейчас, он больше не будет отображаться.", + "noApiKeysForUser": "Ключи API отсутствуют", + "apiKeyDeleteFailed": "Не удалось удалить ключ API.", + "exportUserData": "Экспорт данных", + "exportUserDataDesc": "Загрузите данные о хостах, учетные данные и файловый менеджер этого пользователя в формате JSON. Секретные данные будут расшифрованы.", + "exportUserDataSuccess": "Экспорт пользовательских данных", + "exportUserDataFailed": "Не удалось экспортировать данные пользователя.", + "hostsCount": "{{count}} хосты", + "addHostForUser": "Добавить хост", + "noHostsForUser": "У этого пользователя нет хостов.", + "connectToHost": "Соединять", + "deleteHostConfirm": "Удалить хост \"{{name}}\", принадлежащий {{username}}?", + "hostDeletedSuccess": "Хост удален", + "hostDeleteFailed": "Не удалось удалить хост", + "credentialsCount": "{{count}} учетные данные", + "addCredentialForUser": "Добавить учетные данные", + "noCredentialsForUser": "У этого пользователя нет учетных данных.", + "deleteCredentialConfirm": "Удалить учетные данные \"{{name}}\", принадлежащие {{username}}?", + "credentialDeletedSuccess": "Учетные данные удалены", + "credentialDeleteFailed": "Не удалось удалить учетные данные.", + "snippetsCount": "{{count}} фрагменты", + "addSnippetForUser": "Добавить фрагмент", + "noSnippetsForUser": "У этого пользователя нет фрагментов кода.", + "snippetRequiredFields": "Название и содержимое фрагмента текста обязательны для заполнения.", + "snippetNamePlaceholder": "Название фрагмента", + "snippetContentPlaceholder": "Содержимое команды", + "snippetFolderPlaceholder": "Папка (необязательно)", + "snippetSaved": "Сохранённый фрагмент", + "snippetSaveFailed": "Не удалось сохранить фрагмент", + "deleteSnippetConfirm": "Удалить фрагмент \"{{name}}\", относящийся к {{username}}?", + "snippetDeletedSuccess": "Фрагмент удален", + "snippetDeleteFailed": "Не удалось удалить фрагмент", + "noSessionsForUser": "Активных сессий нет.", + "deleteUserDangerDesc": "Безвозвратно удалить {{username}} и все его данные (хостинги, учетные данные, фрагменты кода, историю). Это действие необратимо.", + "deleteUserConfirm": "Удалить навсегда {{username}} и все их данные?", + "deleteUserAdminBlocked": "Перед удалением этого пользователя необходимо снять с него статус администратора.", "createRoleRequired": "Имя и отображаемое имя обязательны для заполнения.", "createRoleSuccess": "Создана роль \"{{name}}", "createRoleFailed": "Не удалось создать роль", @@ -2800,7 +2920,7 @@ "exportSuccess": "База данных успешно экспортирована.", "exportFailed": "Экспорт базы данных не удался.", "importSelectFile": "Пожалуйста, сначала выберите файл.", - "importCompleted": "Импорт завершен: импортировано {{total}} элементов, {{skipped}} пропущено", + "importCompleted": "Импорт завершен: импортировано {{total}} объектов, {{skipped}} пропущено", "importFailed": "Импорт не удался: {{error}}", "importError": "Импорт базы данных не удался.", "sectionHostDefaults": "Настройки хоста по умолчанию", @@ -2819,26 +2939,35 @@ "hostDefaultsTerminal": "Терминал", "hostDefaultsSessionLogging": "Ведение журнала сессий", "hostDefaultsSessionLoggingDesc": "Запись терминальных сессий на новых хостах по умолчанию.", - "hostDefaultsCommandHistory": "История командования", + "hostDefaultsCommandHistory": "История команд", "hostDefaultsCommandHistoryDesc": "Отслеживание истории команд на новых хостах по умолчанию.", "hostDefaultsSaved": "Сохранены настройки хоста по умолчанию", - "hostDefaultsSaveFailed": "Не удалось сохранить настройки хоста по умолчанию." + "hostDefaultsSaveFailed": "Не удалось сохранить настройки хоста по умолчанию.", + "rolePermissions": { + "count": "{{count}} разрешения", + "editAction": "Редактировать права доступа", + "loadError": "Не удалось загрузить каталог разрешений.", + "saved": "Права доступа к ролям сохранены", + "saveError": "Не удалось сохранить права доступа к ролям.", + "save": "Сохранять", + "saving": "Сохранение..." + } }, "newUi": { "sidebar": { "quickConnect": { - "hostLabel": "Хозяин", + "hostLabel": "Хост", "hostPlaceholder": "192.168.1.1 или example.com", "portLabel": "Порт", "portPlaceholder": "22", "usernameLabel": "Имя пользователя", "usernamePlaceholder": "имя пользователя", - "authLabel": "Авторизация", + "authLabel": "Аутентификация", "passwordLabel": "Пароль", "passwordPlaceholder": "пароль", "privateKeyLabel": "Закрытый ключ", "privateKeyPlaceholder": "Вставьте закрытый ключ...", - "credentialLabel": "Удостоверение личности", + "credentialLabel": "Учётные данные", "credentialPlaceholder": "Выберите сохраненные учетные данные", "connectToTerminal": "Подключиться к терминалу", "connectToFiles": "Подключиться к файлам" @@ -2878,7 +3007,7 @@ "dropHere": "Опустите сюда", "emptyPane": "Пустой", "dashboard": "Панель управления", - "clearSplitScreen": "Четкое разделение экрана", + "clearSplitScreen": "Очистить разделённый экран", "quickAssign": "Быстрое назначение", "alreadyAssigned": "Панель {{index}}", "splitTab": "Разделить вкладку", @@ -2893,7 +3022,7 @@ "hotkeysPrevTab": "Предыдущая вкладка" }, "snippets": { - "title": "Фрагменты", + "title": "Фрагменты кода", "createSnippetTitle": "Создать фрагмент", "createSnippetDescription": "Создайте новый фрагмент команды для быстрого выполнения.", "nameLabel": "Имя", @@ -2903,7 +3032,7 @@ "optional": "Необязательный", "folderLabel": "Папка", "noFolder": "Нет папки (Без категории)", - "commandLabel": "Командование", + "commandLabel": "Команда", "commandPlaceholder": "например, sudo systemctl restart nginx", "cancel": "Отмена", "createSnippetButton": "Создать фрагмент", @@ -2920,10 +3049,10 @@ "selectAll": "Все", "selectNone": "Никто", "noTerminalTabsOpen": "Вкладки терминала не открыты", - "searchPlaceholder": "Поисковые фрагменты...", + "searchPlaceholder": "Поиск фрагментов кода...", "newSnippet": "Новый фрагмент", "newFolder": "Новая папка", - "run": "Бегать", + "run": "Запустить", "noSnippetsInFolder": "В этой папке нет фрагментов кода.", "uncategorized": "Без категории", "editSnippetTitle": "Редактировать фрагмент", @@ -2946,7 +3075,7 @@ "folderEditSuccess": "Папка успешно обновлена.", "folderEditFailed": "Не удалось обновить папку.", "confirmRunMessage": "Запустить \"{{name}}\"?", - "confirmRunButton": "Бегать", + "confirmRunButton": "Запустить", "runSuccess": "Выполнено \"{{name}}\" в {{count}} терминале(ах)", "copySuccess": "Скопировано \"{{name}}\" в буфер обмена", "shareTitle": "Поделиться фрагментом", @@ -2961,21 +3090,21 @@ "currentAccess": "Текущий доступ", "shareLoadError": "Не удалось загрузить и распределить данные.", "loading": "Загрузка...", - "close": "Закрывать", + "close": "Закрыть", "reorderFailed": "Не удалось сохранить порядок фрагментов.", "importExport": "Импорт / Экспорт", "exportBtn": "Экспорт в формате JSON", "importBtn": "Импорт JSON", - "exportSuccess": "Фрагменты успешно экспортированы.", - "exportFailed": "Не удалось экспортировать фрагменты.", + "exportSuccess": "Фрагменты кода успешно экспортированы", + "exportFailed": "Не удалось экспортировать фрагменты кода", "importTitle": "Импорт фрагментов", "importDescription": "Импорт фрагментов кода и папок из JSON-файла, экспортированного Termix.", "importDropOrClick": "Перетащите JSON-файл сюда или нажмите, чтобы выбрать нужный.", "importSelectedFile": "Выбрано: {{name}}", - "importOverwrite": "Замените существующие фрагменты кода с тем же именем и папкой.", + "importOverwrite": "Замените существующие фрагменты кода с тем же именем и папкой", "importStartBtn": "Импорт", "importSuccess": "Импорт завершен: {{snippets}} фрагмент(ы) добавлены, {{updated}} обновлен, {{skipped}} пропущен, {{folders}} папка(и) добавлены", - "importFailed": "Не удалось импортировать фрагменты кода.", + "importFailed": "Не удалось импортировать фрагменты кода", "importInvalidFile": "Недопустимый файл: ожидался объект JSON с фрагментами кода или массивами папок.", "targetHostsLabel": "Целевые хосты", "targetHostsHint": "Назначьте хосты для запуска этого фрагмента кода напрямую, без открытого терминала.", @@ -2991,14 +3120,18 @@ "executionFailed": "Неуспешный" }, "userProfile": { + "donateTitle": "Поддержите Termix!", + "donateDescription": "Termix — бесплатный, самодостаточный сервис, созданный всего несколькими людьми в свободное время. Если он сэкономил вам время или деньги, пожертвование в криптовалюте поможет продолжить его работу.", + "donateMilestones": "Пожертвования помогают финансировать исследования и изучение того, что необходимо для создания поддержки SAML, Kubernetes и агентов. Следите за прогрессом и делайте пожертвования.", + "donateButton": "Пожертвовать криптовалюту", "storageModeLocal": "Браузер", "storageModeCloud": "База данных", "storageModeDescription": "Браузер сохраняет настройки только в этом браузере. База данных синхронизируется с сервером и загружается на любое устройство.", "resetToDefaults": "Сбросить до значений по умолчанию", "resetToDefaultsSuccess": "Настройки сброшены до значений по умолчанию.", - "storageModeSwitch": "Хранилище предпочтений", - "sectionAccount": "Счет", - "sectionAppearance": "Появление", + "storageModeSwitch": "Место хранения настроек", + "sectionAccount": "Аккаунт", + "sectionAppearance": "Оформление", "sectionSecurity": "Безопасность", "sectionApiKeys": "Ключи API", "sectionC2sTunnels": "Туннели C2S", @@ -3006,11 +3139,15 @@ "roleLabel": "Роль", "roleAdministrator": "Администратор", "authMethodLabel": "Метод аутентификации", - "authMethodLocal": "Местный", + "authMethodLocal": "Локальный", "twoFaLabel": "2FA", "twoFaOn": "На", "twoFaOff": "Выключенный", "versionLabel": "Версия", + "betaProgramTitle": "Бета-программа", + "betaProgramDescription": "Оцените новые функции на ранней стадии разработки, используя еженедельный тег Docker :beta. Нестабильная версия, не предназначена для использования в продакшене.", + "betaProgramFeedback": "Нашли ошибку? Сообщите о ней здесь.", + "betaProgramLearnMore": "Узнать больше", "deleteAccount": "Удалить аккаунт", "deleteAccountDescription": "Удалить свой аккаунт навсегда", "changeServerDescription": "Переключитесь на другой сервер Termix.", @@ -3025,7 +3162,7 @@ "settingsTerminal": "Терминал", "commandAutocomplete": "Автозаполнение команд", "commandAutocompleteDesc": "Отображать автозаполнение во время набора текста", - "terminalLinkBehavior": "Ссылка на терминал Щелчок", + "terminalLinkBehavior": "Клик по ссылкам в терминале", "terminalLinkBehaviorDesc": "Поведение по умолчанию при нажатии на ссылки в терминале", "historyTracking": "Отслеживание истории", "historyTrackingDesc": "Отслеживание команд терминала", @@ -3036,21 +3173,21 @@ "confirmTabClose": "Подтвердить закрытие вкладки", "confirmTabCloseDesc": "Спросите разрешения, прежде чем закрывать вкладки терминала.", "settingsSidebar": "Боковая панель", - "showHostTags": "Показать теги ведущего", + "showHostTags": "Показать теги хоста", "showHostTagsDesc": "Отображать теги в списке хостов", - "hostTrayOnClick": "Нажмите, чтобы развернуть действия с хостом.", - "hostTrayOnClickDesc": "Всегда отображайте кнопки подключения; щелкните по ним, чтобы развернуть параметры управления, вместо того чтобы наводить курсор на них.", + "hostTrayOnClick": "Нажмите, чтобы открыть действия хоста", + "hostTrayOnClickDesc": "Всегда показывать кнопки подключения; раскрывать управление по нажатию, а не при наведении", "compactHostView": "Компактный вид хоста", "compactHostViewDesc": "Сверните список всех хостов в одну строку, отображающую только его имя и адрес.", "statusColors": "Реальные цвета статуса", "statusColorsDesc": "Вместо акцентного цвета используйте зеленый/красный для обозначения статуса онлайн/офлайн.", - "pinAppRail": "Панель приложений Pin", + "pinAppRail": "Закрепить панель приложений", "pinAppRailDesc": "Левая боковая панель приложения всегда должна быть развернута, а не расширяться при наведении курсора.", "expandAppRailOnHover": "Развернуть панель приложений при наведении курсора", "expandAppRailOnHoverDesc": "Разрешите левой боковой панели приложения расширяться при наведении на неё указателя мыши.", "settingsNavigation": "Навигация", "navigationTabsDesc": "Выберите, какие вкладки будут отображаться в панели приложений.", - "settingsSnippets": "Фрагменты", + "settingsSnippets": "Фрагменты кода", "foldersCollapsed": "Папки свернуты", "foldersCollapsedDesc": "Папки по умолчанию сворачиваются.", "confirmExecution": "Подтвердить выполнение", @@ -3062,12 +3199,12 @@ "totpEnabled": "Двухфакторная аутентификация включена.", "totpDisabled": "Добавить дополнительную защиту при входе в систему.", "disable": "Запрещать", - "enable": "Давать возможность", + "enable": "Включить", "setupTotp": "Настройка TOTP", "qrCode": "QR-код", "totpInstructions": "Отсканируйте QR-код или введите секретный код в приложении-аутентификаторе, затем введите 6-значный код.", "totpCodePlaceholder": "000000", - "verify": "Проверять", + "verify": "Проверить", "changePassword": "Изменить пароль", "currentPasswordLabel": "Текущий пароль", "currentPasswordPlaceholder": "Текущий пароль", @@ -3093,7 +3230,7 @@ "apiKeyPermissionsHint": "Ключи наследуют права доступа пользователя, создавшего их.", "roleUser": "Пользователь", "authMethodDual": "Двойная аутентификация", - "authMethodOidc": "ОИДК", + "authMethodOidc": "OIDC", "totpSetupFailed": "Не удалось запустить настройку TOTP.", "totpEnter6Digits": "Введите 6-значный код", "totpEnabledSuccess": "Включена двухфакторная аутентификация", @@ -3108,19 +3245,19 @@ "totpVerifyTitle": "Подтвердите код", "totpBackupTitle": "Резервные коды", "totpDownloadBackup": "Скачать резервные коды", - "passkeys": "Пароли", + "passkeys": "Ключи доступа", "passkeysDesc": "Используйте учетные данные WebAuthn/FIDO2 для входа в систему без пароля.", "passkeyName": "Имя пароля", "passkeyUvPreferred": "Предпочтительный", "passkeyUvRequired": "Необходимый", - "passkeyUvDiscouraged": "Разочарованный", - "addPasskey": "Добавить пароль", + "passkeyUvDiscouraged": "Не рекомендуется", + "addPasskey": "Добавить ключ доступа", "noPasskeys": "Пароли не зарегистрированы.", "passkeyAdded": "Добавлен пароль", "passkeyAddFailed": "Не удалось добавить пароль.", "passkeyDeleted": "Пароль удален", "passkeyDeleteFailed": "Не удалось удалить пароль.", - "done": "Сделанный", + "done": "Готово", "secretCopied": "Секретный ключ скопирован в буфер обмена", "apiKeyNameRequired": "Имя ключа обязательно для ввода.", "apiKeyCreated": "Создан ключ API \"{{name}}", @@ -3141,12 +3278,12 @@ "themeSystem": "Система", "themeLight": "Свет", "themeDark": "Темный", - "themeDracula": "Дракула", - "themeCatppuccin": "Кэтпучин", - "themeNord": "Норд", - "themeSolarized": "Соляризованный", + "themeDracula": "Dracula", + "themeCatppuccin": "Catppuccin", + "themeNord": "Nord", + "themeSolarized": "Solarized", "themeTokyoNight": "Токийская ночь", - "themeOneDark": "Один Темный", + "themeOneDark": "One Dark", "themeGruvbox": "Gruvbox" } } @@ -3158,9 +3295,9 @@ "tmuxUnavailable": "tmux не установлен на этом хосте.", "noSessions": "На этом хосте нет сессий tmux.", "noHostSelected": "Хост не выбран", - "attached": "Прикрепил", - "detached": "Отдельно стоящий", - "attach": "Прикреплять", + "attached": "Прикреплён", + "detached": "Отсоединено", + "attach": "Прикрепить", "editTags": "Редактировать теги", "tagsHint": "Теги, разделённые запятыми (например, YOLO, lab, training)", "tagsSaved": "Сохраненные теги", @@ -3173,7 +3310,7 @@ "newSession": "Новая сессия", "newSessionHint": "Название сессии (буквы, цифры, _ @ % + = -)", "newSessionPlaceholder": "моя сессия", - "create": "Создавать", + "create": "Создать", "sessionCreated": "Создана сессия \"{{name}}", "sessionCreateFailed": "Не удалось создать сессию.", "splitRight": "Разделить вправо", @@ -3193,23 +3330,23 @@ "sessionRenamed": "Сессия переименована в \"{{name}}\"", "sessionRenameFailed": "Не удалось переименовать сессию.", "editTagsTitle": "Редактировать теги для \"{{name}}\"", - "killPane": "Панель отключения", + "killPane": "Завершить панель", "resizeTree": "Перетащите для изменения размера — дважды щелкните для сброса.", "reattach": "Повторно прикрепите (исправляет искаженное изображение)", - "killWindow": "Окно убийства", + "killWindow": "Завершить окно", "killWindowTitle": "Окно уничтожения {{index}} из \"{{session}}\"?", "killWindowBody": "Все окна и процессы в этом окне будут завершены. Завершение работы последнего окна завершает сессию.", - "windowKillFailed": "Не удалось уничтожить окно", + "windowKillFailed": "Не удалось завершить окно", "statusActivity": "Активность", - "statusWindows": "Windows", - "statusPanes": "стекла", + "statusWindows": "Окна", + "statusPanes": "панели", "statusTags": "Теги", "killPaneTitle": "Панель закрытия {{id}}?", "killPaneBody": "Процесс, запущенный в этой панели, будет завершен. Завершение работы последней панели закроет ее окно.", "paneKillFailed": "Не удалось закрыть панель", "killSessionTitle": "Завершить сессию \"{{name}}\"?", "killSessionBody": "Все окна и запущенные процессы в этой сессии будут завершены. Это действие необратимо.", - "kill": "Убийство", + "kill": "Завершить", "sessionKilled": "Сессия \"{{name}}\" завершена", "sessionKillFailed": "Не удалось завершить сессию", "hostUnreachable": "Не удалось подключиться к хосту. Убедитесь, что он находится в сети и доступен.", @@ -3228,43 +3365,43 @@ "timeDays": "{{count}}дней назад" }, "mobileKeyboard": { - "shift": "Сдвиг", + "shift": "Shift", "ctrl": "Ctrl", - "esc": "Эск", - "tab": "Вкладка", + "esc": "Esc", + "tab": "Tab", "backTab": "⇥", "arrowUp": "Стрелка вверх", - "arrowDown": "Стрела вниз", + "arrowDown": "Стрелка вниз", "arrowLeft": "Стрелка влево", "arrowRight": "Стрелка вправо", - "home": "Дом", - "end": "Конец", + "home": "Home", + "end": "End", "pageUp": "PgUp", "pageDown": "PgDn", "delete": "Дел", "editQuickKeys": "Редактировать быстрые клавиши", "quickKeysTitle": "Быстрые клавиши", "quickKeysDesc": "Нажмите × для удаления. Поддерживается до 8 символов.", - "quickKeyPlaceholder": "например, судо ", - "addQuickKey": "Добавлять", - "removeQuickKey": "Удалять", + "quickKeyPlaceholder": "например sudo ", + "addQuickKey": "Добавить", + "removeQuickKey": "Удалить", "resetDefaults": "Сбросить до значений по умолчанию", - "done": "Сделанный" + "done": "Готово" }, "serial": { - "title": "Серийный номер", + "title": "Последовательный порт", "portLabel": "Порт", "portPlaceholder": "/dev/ttyUSB0 или COM3", "baudRateLabel": "Скорость передачи данных", - "dataBitsLabel": "Данные", - "stopBitsLabel": "Останавливаться", + "dataBitsLabel": "Биты данных", + "stopBitsLabel": "Стоп-биты", "parityLabel": "Паритет", - "parityNone": "Никто", - "parityEven": "Даже", - "parityOdd": "Странный", + "parityNone": "Нет", + "parityEven": "Чётный", + "parityOdd": "Нечётный", "connect": "Подключиться к последовательному порту", "disconnect": "Отключить", - "refreshPorts": "Порты обновления", + "refreshPorts": "Обновить порты", "connected": "Подключено к {{path}} со скоростью {{baud}} бод", "disconnected": "Последовательный порт отключен", "connectionError": "Не удалось открыть последовательный порт.", @@ -3280,12 +3417,12 @@ "historySuffix": "История", "cpuMemoryDisk": "Процессор / Память / Диск", "network": "Сеть", - "download": "РКС", + "download": "RX", "upload": "TX", "noData": "Данные за этот период времени отсутствуют.", - "custom": "Обычай", + "custom": "Произвольный", "to": "к", - "apply": "Применять", + "apply": "Применить", "viewHistory": "Просмотреть историю" }, "alerts": { @@ -3297,13 +3434,13 @@ "noChannels": "Каналы уведомлений не настроены", "rulesDesc": "Правила оповещения запускают уведомления.", "channelsDesc": "Куда отправляются уведомления об оповещениях", - "acknowledge": "Сознавать", - "ackAll": "Щелкнуть Все", + "acknowledge": "Подтвердить", + "ackAll": "Подтвердить все", "allAcknowledged": "Все оповещения подтверждены.", "ackFailed": "Не подтвердил оповещение", "ackAllFailed": "Не удалось подтвердить все оповещения.", "showAcknowledged": "Показать все", - "hideAcknowledged": "Спрячься", + "hideAcknowledged": "Скрыть подтверждённые", "addChannel": "Добавить канал", "editChannel": "Редактировать канал", "channelName": "Имя", @@ -3324,7 +3461,7 @@ "editRule": "Изменить правило оповещения", "ruleName": "Название правила", "ruleNameRequired": "Имя обязательно для указания.", - "triggerType": "Курок", + "triggerType": "Тип триггера", "thresholdValue": "Порог (%)", "durationSeconds": "Длительность (секунды, 0 = немедленный выстрел)", "cooldownMinutes": "Время восстановления (минуты)", diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index 74510040..4f083dad 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Обрасци имена одвојени зарезима који омогућавају Докеру да упари госте", "proxmoxPreferredRanges": "Жељени IP опсези", "proxmoxPreferredRangesDesc": "Префикси одвојени зарезима по редоследу приоритета за избор IP адресе када гост има више интерфејса", + "proxmoxAutoSync": "Аутоматска синхронизација гостију", + "proxmoxAutoSyncDesc": "Периодично откривајте овај Proxmox чвор и креирајте или ажурирајте увезене гостујуће хостове док је ваша сесија откључана.", + "proxmoxSyncInterval": "Интервал синхронизације (минути)", + "proxmoxSyncIntervalDesc": "Минимум 5 минута. Планер прескаче закључане корисничке сесије.", + "proxmoxMarkMissing": "Означи госте који недостају", + "proxmoxMarkMissingDesc": "Додајте ознаку proxmox-missing када претходно увезени гост нестане уместо да га избришете.", + "proxmoxLastSync": "Последња синхронизација", + "proxmoxLastSyncNever": "Још није синхронизовано", + "proxmoxLastSyncNoResult": "Нема детаља о резултатима", + "proxmoxLastSyncSummary": "{{created}} креирано, {{updated}} ажурирано, {{markedMissing}} недостаје, {{skipped}} прескочено", + "proxmoxLastSyncStatus": { + "success": "Успех", + "error": "Неуспешно", + "pending": "На чекању" + }, "proxmoxDiscoverAction": "Откријте и увезите госте Proxmox-а", "proxmoxImportTitle": "Увоз из Proxmox-а", "proxmoxSelectHost": "Изаберите Proxmox хост…", @@ -1167,7 +1182,57 @@ "shareHost": "Дели хост", "shareHostTitle": "Подели: {{name}}", "sharing": { - "requiresCredential": "Овај хост мора да користи акредитив да би омогућио дељење. Прво измените хост и доделите акредитив." + "loadError": "Учитавање података о дељењу није успело. Молимо покушајте поново.", + "shareWithSection": "Подели са", + "usersTab": "Корисници", + "rolesTab": "Улоге", + "searchPlaceholder": "Претражите кориснике или улоге...", + "noMatches": "Није пронађено ниједно подударање", + "permissionLevelLabel": "Ниво дозволе", + "levels": { + "connect": { + "label": "Повежи се", + "description": "Само отворене сесије: терминал, удаљена радна површина, менаџер датотека, тунели и Докер. Нема приступа конфигурацији хоста." + }, + "view": { + "label": "Поглед", + "description": "Повежите се, плус погледајте конфигурацију хоста. Тајне се никада не приказују." + }, + "edit": { + "label": "Измени", + "description": "Преглед и измена хоста. Тајне се могу заменити, али никада читати; доделе акредитива остају само за власника." + }, + "manage": { + "label": "Управљај", + "description": "Уредите, плус делите хост са другима, мењајте нивое дозвола и опозовите приступ." + } + }, + "expiryLabel": "Истек приступа", + "expiry": { + "never": "Никад", + "oneHour": "1 сат", + "oneDay": "24 сата", + "sevenDays": "7 дана", + "thirtyDays": "30 дана", + "custom": "Прилагођено" + }, + "customHoursPlaceholder": "Сати до истека приступа", + "shareButton": "Дели", + "shareWithCount": "Подели ({{count}})", + "currentAccess": "Тренутни приступ", + "noAccessEntries": "Овај хост још није дељен", + "grantedBy": "Додељено од стране", + "expires": "Истиче", + "expired": "Истекло", + "never": "Никад", + "revoke": "Поништи", + "accessUpdated": "Приступ је ажуриран", + "accessUpdateFailed": "Ажурирање приступа није успело", + "sharedBadge": "Дељено", + "sharedBadgeTooltip": "Дели {{owner}} ({{level}} приступ)", + "viewOnlyBanner": "Овај хост дели са вама {{owner}} са приступом за преглед. Конфигурација је само за читање.", + "sharedEditBanner": "Овај хост дели са вама {{owner}} са приступом за уређивање. Промене се примењују на правог хоста; референце за аутентификацију може променити само власник.", + "ownerOnlyControl": "Само власник хоста може ово да промени" }, "guac": { "connection": "Веза", @@ -1302,28 +1367,10 @@ "backspaceKey": "Тастер Backspace", "saveHostFirst": "Прво сачувајте домаћина.", "sharingOptionsAfterSave": "Опције дељења су доступне након што је хост сачуван.", - "sharingLoadError": "Учитавање података за дељење није успело. Проверите везу и покушајте поново.", - "shareHostSection": "Дели хост", - "shareWithUser": "Подели са корисником", - "shareWithRole": "Дели са улогом", - "selectUser": "Изаберите корисника", - "selectRole": "Изаберите улогу", - "selectUserOption": "Изаберите корисника...", - "selectRoleOption": "Изаберите улогу...", "permissionLevel": "Ниво дозволе", - "expiresInHours": "Истиче за (сати)", - "noExpiryPlaceholder": "Оставите празно ако нема рока истека", - "shareBtn": "Дели", - "currentAccess": "Тренутни приступ", "typeHeader": "Тип", "targetHeader": "Циљ", "permissionHeader": "Дозвола", - "grantedByHeader": "Доделио/ла", - "expiresHeader": "Истиче", - "noAccessEntries": "Још нема уноса за приступ.", - "expiredLabel": "Истекло", - "neverLabel": "Никад", - "revokeBtn": "Поништи", "cancelBtn": "Откажи", "savingBtn": "Чување...", "updateHostBtn": "Ажурирај хост", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Пријава лозинком је онемогућена. Користите шифру или екстерног добављача аутентификације.", "signInWithPasskey": "Пријавите се помоћу лозинке", "passkeyLoginFailed": "Пријава лозинком није успела", - "attemptsRemaining": "{{count}} преосталих покушаја" + "attemptsRemaining": "{{count}} преосталих покушаја", + "confirmResetDataWipe": "Овај налог се није пријављивао од надоградње шифровања, тако да се његови сачувани подаци не могу вратити без старе лозинке. Ресетовање ће трајно избрисати његове хостове, акредитиве и фрагменте. Желите да наставите?" }, "hostKey": { "verifyNewHost": "Проверите SSH кључ хоста", @@ -2323,6 +2371,13 @@ "serviceLinks": "Линкови услуга", "homepagePreview": "Преглед почетне странице" }, + "donation": { + "title": "Уживате у Термиксу?", + "body": "Termix је бесплатан и отвореног кода, који развија и одржава двочлани тим у слободно време. Ако замени комерцијални алат за који бисте иначе плаћали, донација помаже у покривању трошкова хостинга и одржавању развоја. Тренутно, донације су само у криптовалутама.", + "milestones": "Донације такође помажу у финансирању времена за истраживање и учење шта је потребно за изградњу функција попут подршке за SAML и Kubernetes. Погледајте напредак на страници за донације.", + "cta": "Донирајте", + "dismiss": "Можда касније" + }, "dashboardTab": { "stable": "СТАБИЛНО", "hostsOnline": "Домаћини на мрежи", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Ажурирање статуса администратора није успело", "allSessionsRevoked": "Све сесије су опозване", "revokeSessionsFailed": "Опозивање сесија није успело", + "manageUserData": "Управљајте корисничким подацима", + "backToUsers": "Назад на кориснике", + "manageTabAccount": "Налог", + "manageTabHosts": "Домаћини", + "manageTabCredentials": "Акредитиви", + "manageTabSnippets": "Исечци", + "manageTabSessions": "Сесије", + "manageTabDanger": "Опасност", + "manageEditorBack": "Назад на податке корисника {{username}}", + "dataLockedBadge": "ЗАКЉУЧАНО", + "dataLockedNotice": "Подаци корисника {{username}}остају закључани до њиховог следећег пријављивања. Њихови хостови, акредитиви и исечци не могу бити прегледани или уређивани до тада.", + "resetPasswordTitle": "Ресетуј лозинку", + "resetPasswordOidcOnly": "Овај корисник се пријављује преко екстерног провајдера и нема лозинку.", + "resetPasswordPlaceholder": "Нова лозинка", + "resetPasswordBtn": "Ресетуј", + "resetPasswordWorking": "Ресетовање...", + "resetPasswordSuccess": "Ресетовање лозинке", + "resetPasswordSuccessWiped": "Ресетовање лозинке. Шифровани подаци корисника су обрисани.", + "resetPasswordFailed": "Ресетовање лозинке није успело", + "resetPasswordConfirmWipe": "{{username}} се није пријавио од надоградње шифровања, тако да се његови подаци не могу повратити. Ресетовањем сада ће се избрисати његови хостови, акредитиви и фрагменти. Желите ли да наставите?", + "totpSectionTitle": "Двофакторска аутентификација", + "totpStatusEnabled": "TOTP је омогућен за овог корисника", + "totpStatusDisabled": "TOTP није омогућен за овог корисника", + "disableTotp": "Онемогући", + "disableTotpConfirm": "Онемогућите двофакторску аутентификацију за {{username}}? Моћи ће да се пријаве само са својом лозинком.", + "totpDisabledSuccess": "ТОТП је онемогућен", + "totpDisableFailed": "Онемогућавање TOTP-а није успело", + "manageApiKeys": "API кључеви", + "apiKeyNamePlaceholder": "Назив кључа", + "apiKeyCopyNotice": "Копирајте овај кључ сада, више се неће приказивати.", + "noApiKeysForUser": "Нема API кључева", + "apiKeyDeleteFailed": "Брисање API кључа није успело", + "exportUserData": "Извоз података", + "exportUserDataDesc": "Преузмите хостове, акредитиве и податке менаџера датотека овог корисника као JSON. Тајне су дешифроване.", + "exportUserDataSuccess": "Извезени подаци корисника", + "exportUserDataFailed": "Извоз корисничких података није успео", + "hostsCount": "{{count}} домаћини", + "addHostForUser": "Додај хоста", + "noHostsForUser": "Овај корисник нема хостове", + "connectToHost": "Повежи се", + "deleteHostConfirm": "Да ли желите да обришете хост „{{name}}“ који припада {{username}}?", + "hostDeletedSuccess": "Хост је обрисан", + "hostDeleteFailed": "Брисање хоста није успело", + "credentialsCount": "{{count}} акредитиви", + "addCredentialForUser": "Додај акредитив", + "noCredentialsForUser": "Овај корисник нема акредитиве", + "deleteCredentialConfirm": "Обришите акредитив „{{name}}“ који припада {{username}}?", + "credentialDeletedSuccess": "Акредитив је обрисан", + "credentialDeleteFailed": "Брисање акредитива није успело", + "snippetsCount": "{{count}} исечци", + "addSnippetForUser": "Додај исечак", + "noSnippetsForUser": "Овај корисник нема исечке кода", + "snippetRequiredFields": "Назив и садржај фрагмента су обавезни", + "snippetNamePlaceholder": "Назив исечка", + "snippetContentPlaceholder": "Садржај команде", + "snippetFolderPlaceholder": "Фасцикла (опционо)", + "snippetSaved": "Исечак је сачуван", + "snippetSaveFailed": "Чување фрагмента није успело", + "deleteSnippetConfirm": "Да ли желите да обришете исечак „{{name}}“ који припада {{username}}?", + "snippetDeletedSuccess": "Исечак је обрисан", + "snippetDeleteFailed": "Брисање исечка није успело", + "noSessionsForUser": "Нема активних сесија", + "deleteUserDangerDesc": "Трајно обриши {{username}} и све њихове податке (хостове, акредитиве, фрагменте, историју). Ова радња се не може поништити.", + "deleteUserConfirm": "Трајно избрисати {{username}} и све њихове податке?", + "deleteUserAdminBlocked": "Уклоните администраторски статус пре брисања овог корисника.", "createRoleRequired": "Име и име за приказ су обавезни", "createRoleSuccess": "Улога „{{name}}“ је креирана", "createRoleFailed": "Није успело креирање улоге", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Историја команди", "hostDefaultsCommandHistoryDesc": "Праћење историје команди на новим хостовима подразумевано", "hostDefaultsSaved": "Подразумевана подешавања хоста су сачувана", - "hostDefaultsSaveFailed": "Чување подразумеваних подешавања хоста није успело" + "hostDefaultsSaveFailed": "Чување подразумеваних подешавања хоста није успело", + "rolePermissions": { + "count": "{{count}} дозволе", + "editAction": "Измени дозволе", + "loadError": "Учитавање каталога дозвола није успело", + "saved": "Дозволе за улогу су сачуване", + "saveError": "Чување дозвола за улогу није успело", + "save": "Сачувај", + "saving": "Чување..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Неуспешно" }, "userProfile": { + "donateTitle": "Нека Термикс остане жив", + "donateDescription": "Termix је бесплатан, самостално хостован и изградило га је само неколико људи у своје слободно време. Ако вам је уштедео време или новац, донација криптовалута помаже да се одржи.", + "donateMilestones": "Донације помажу у финансирању времена за истраживање и учење шта је потребно за изградњу подршке за SAML, Kubernetes и агенте. Пратите напредак и донирајте.", + "donateButton": "Донирајте криптовалуту", "storageModeLocal": "Прегледач", "storageModeCloud": "База података", "storageModeDescription": "Прегледач чува подешавања само у овом прегледачу. База података се синхронизује са сервером и учитава на било ком уређају.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Укључено", "twoFaOff": "Искључено", "versionLabel": "Верзија", + "betaProgramTitle": "Бета програм", + "betaProgramDescription": "Испробајте предстојеће функције рано уз недељну ознаку :beta Docker. Нестабилно, није за продукцију.", + "betaProgramFeedback": "Пронашли сте грешку? Пријавите је овде.", + "betaProgramLearnMore": "Сазнајте више", "deleteAccount": "Обриши налог", "deleteAccountDescription": "Трајно избришите свој налог", "changeServerDescription": "Пребаците се на други Termix backend сервер", diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index cfad386c..c1a26974 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Kommaavgränsade namnmönster som gör det möjligt för Docker att matcha gäster", "proxmoxPreferredRanges": "Föredragna IP-intervall", "proxmoxPreferredRangesDesc": "Kommaavgränsade prefix i prioritetsordning för IP-val när en gäst har flera gränssnitt", + "proxmoxAutoSync": "Synkronisera gäster automatiskt", + "proxmoxAutoSyncDesc": "Upptäck regelbundet denna Proxmox-nod och skapa eller uppdatera importerade gästvärdar medan din session är upplåst.", + "proxmoxSyncInterval": "Synkroniseringsintervall (minuter)", + "proxmoxSyncIntervalDesc": "Minst 5 minuter. Schemaläggaren hoppar över låsta användarsessioner.", + "proxmoxMarkMissing": "Markera saknade gäster", + "proxmoxMarkMissingDesc": "Lägg till en proxmox-missing-tagg när en tidigare importerad gäst försvinner istället för att ta bort den.", + "proxmoxLastSync": "Senaste synkronisering", + "proxmoxLastSyncNever": "Inte synkroniserad än", + "proxmoxLastSyncNoResult": "Inga resultatdetaljer", + "proxmoxLastSyncSummary": "{{created}} skapad, {{updated}} uppdaterad, {{markedMissing}} saknas, {{skipped}} hoppad över", + "proxmoxLastSyncStatus": { + "success": "Framgång", + "error": "Misslyckades", + "pending": "I avvaktan på" + }, "proxmoxDiscoverAction": "Upptäck och importera Proxmox-gäster", "proxmoxImportTitle": "Importera från Proxmox", "proxmoxSelectHost": "Välj en Proxmox-värd…", @@ -1167,7 +1182,57 @@ "shareHost": "Dela värd", "shareHostTitle": "Dela: {{name}}", "sharing": { - "requiresCredential": "Den här värden måste använda en autentiseringsuppgift för att aktivera delning. Redigera värden och tilldela en autentiseringsuppgift först." + "loadError": "Misslyckades med att ladda delningsdata. Försök igen.", + "shareWithSection": "Dela med", + "usersTab": "Användare", + "rolesTab": "Roller", + "searchPlaceholder": "Sök användare eller roller...", + "noMatches": "Inga träffar hittades", + "permissionLevelLabel": "Behörighetsnivå", + "levels": { + "connect": { + "label": "Ansluta", + "description": "Endast öppna sessioner: terminal, fjärrskrivbord, filhanterare, tunnlar och Docker. Ingen åtkomst till värdkonfigurationen." + }, + "view": { + "label": "Se", + "description": "Anslut, plus se värdkonfigurationen. Hemligheter visas aldrig." + }, + "edit": { + "label": "Redigera", + "description": "Visa och ändra värden. Hemligheter kan ersättas men aldrig läsas; tilldelningar av autentiseringsuppgifter förblir endast ägarens ansvar." + }, + "manage": { + "label": "Hantera", + "description": "Redigera, plus dela värden med andra, ändra behörighetsnivåer och återkalla åtkomst." + } + }, + "expiryLabel": "Åtkomstutgång", + "expiry": { + "never": "Aldrig", + "oneHour": "1 timme", + "oneDay": "24 timmar", + "sevenDays": "7 dagar", + "thirtyDays": "30 dagar", + "custom": "Beställnings" + }, + "customHoursPlaceholder": "Timmar tills åtkomsten upphör", + "shareButton": "Dela", + "shareWithCount": "Dela ({{count}})", + "currentAccess": "Nuvarande åtkomst", + "noAccessEntries": "Denna värd har inte delats ännu", + "grantedBy": "Beviljad av", + "expires": "Utgår", + "expired": "Utgången", + "never": "Aldrig", + "revoke": "Återkalla", + "accessUpdated": "Åtkomst uppdaterad", + "accessUpdateFailed": "Misslyckades med att uppdatera åtkomsten", + "sharedBadge": "Delad", + "sharedBadgeTooltip": "Delad av {{owner}} ({{level}} åtkomst)", + "viewOnlyBanner": "Denna värd delas med dig av {{owner}} med läsbehörighet. Konfigurationen är skrivskyddad.", + "sharedEditBanner": "Denna värd delas med dig av {{owner}} med redigeringsåtkomst. Ändringarna gäller för den verkliga värden; autentiseringsreferenser kan endast ändras av ägaren.", + "ownerOnlyControl": "Endast värdägaren kan ändra detta" }, "guac": { "connection": "Förbindelse", @@ -1302,28 +1367,10 @@ "backspaceKey": "Backstegstangenten", "saveHostFirst": "Spara värden först.", "sharingOptionsAfterSave": "Delningsalternativ är tillgängliga efter att värden har sparats.", - "sharingLoadError": "Misslyckades med att ladda delningsdata. Kontrollera din anslutning och försök igen.", - "shareHostSection": "Dela värd", - "shareWithUser": "Dela med användaren", - "shareWithRole": "Dela med roll", - "selectUser": "Välj användare", - "selectRole": "Välj roll", - "selectUserOption": "Välj en användare...", - "selectRoleOption": "Välj en roll...", "permissionLevel": "Behörighetsnivå", - "expiresInHours": "Utgår om (timmar)", - "noExpiryPlaceholder": "Lämna tomt för inget utgångsdatum", - "shareBtn": "Dela", - "currentAccess": "Nuvarande åtkomst", "typeHeader": "Typ", "targetHeader": "Mål", "permissionHeader": "Tillstånd", - "grantedByHeader": "Beviljad av", - "expiresHeader": "Utgår", - "noAccessEntries": "Inga åtkomstposter ännu.", - "expiredLabel": "Utgången", - "neverLabel": "Aldrig", - "revokeBtn": "Återkalla", "cancelBtn": "Avboka", "savingBtn": "Sparande...", "updateHostBtn": "Uppdatera värd", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Lösenordsinloggning är inaktiverad. Använd en lösenordsnyckel eller en extern autentiseringsleverantör.", "signInWithPasskey": "Logga in med lösenord", "passkeyLoginFailed": "Inloggning med lösenordsnyckel misslyckades", - "attemptsRemaining": "{{count}} försök återstår" + "attemptsRemaining": "{{count}} försök återstår", + "confirmResetDataWipe": "Det här kontot har inte loggats in sedan krypteringsuppgraderingen, så dess lagrade data kan inte återställas utan det gamla lösenordet. Om du återställer kontot tas dess värdar, inloggningsuppgifter och kodavsnitt permanent bort. Vill du fortsätta?" }, "hostKey": { "verifyNewHost": "Verifiera SSH-värdnyckel", @@ -2323,6 +2371,13 @@ "serviceLinks": "Servicelänkar", "homepagePreview": "Förhandsvisning av startsidan" }, + "donation": { + "title": "Gillar du Termix?", + "body": "Termix är gratis och öppen källkod, byggt och underhållet av ett team på två personer på vår fritid. Om det ersätter ett kommersiellt verktyg som du annars skulle betala för, hjälper en donation till att täcka hostingkostnader och hålla utvecklingen igång. För närvarande är donationer endast kryptovalutor.", + "milestones": "Donationer hjälper också till att finansiera tiden som krävs för att undersöka och lära sig vad som behövs för att bygga funktioner som SAML och Kubernetes-stöd. Se framstegen på donationssidan.", + "cta": "Donera", + "dismiss": "Kanske senare" + }, "dashboardTab": { "stable": "STABIL", "hostsOnline": "Värdar online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Misslyckades med att uppdatera administratörsstatusen", "allSessionsRevoked": "Alla sessioner återkallade", "revokeSessionsFailed": "Misslyckades med att återkalla sessioner", + "manageUserData": "Hantera användardata", + "backToUsers": "Tillbaka till användarna", + "manageTabAccount": "Konto", + "manageTabHosts": "Värdar", + "manageTabCredentials": "Referenser", + "manageTabSnippets": "Snuttar", + "manageTabSessions": "Sessioner", + "manageTabDanger": "Fara", + "manageEditorBack": "Tillbaka till {{username}}s data", + "dataLockedBadge": "LÅST", + "dataLockedNotice": "{{username}}s data förblir låsta tills de loggar in nästa gång. Deras värdar, inloggningsuppgifter och utdrag kan inte visas eller redigeras förrän då.", + "resetPasswordTitle": "Återställ lösenord", + "resetPasswordOidcOnly": "Den här användaren loggar in via en extern leverantör och har inget lösenord.", + "resetPasswordPlaceholder": "Nytt lösenord", + "resetPasswordBtn": "Återställa", + "resetPasswordWorking": "Återställer...", + "resetPasswordSuccess": "Återställning av lösenord", + "resetPasswordSuccessWiped": "Lösenordsåterställning. Användarens krypterade data har raderats.", + "resetPasswordFailed": "Misslyckades med att återställa lösenordet", + "resetPasswordConfirmWipe": "{{username}} har inte loggat in sedan krypteringsuppgraderingen, så deras data kan inte återställas. Om du återställer nu raderas deras värdar, inloggningsuppgifter och kodavsnitt. Vill du fortsätta?", + "totpSectionTitle": "Tvåfaktorsautentisering", + "totpStatusEnabled": "TOTP är aktiverat för den här användaren", + "totpStatusDisabled": "TOTP är inte aktiverat för den här användaren", + "disableTotp": "Inaktivera", + "disableTotpConfirm": "Inaktivera tvåfaktorsautentisering för {{username}}? De kommer bara att kunna logga in med sitt lösenord.", + "totpDisabledSuccess": "TOTP inaktiverad", + "totpDisableFailed": "Misslyckades med att inaktivera TOTP", + "manageApiKeys": "API-nycklar", + "apiKeyNamePlaceholder": "Nyckelnamn", + "apiKeyCopyNotice": "Kopiera den här nyckeln nu, den kommer inte att visas igen.", + "noApiKeysForUser": "Inga API-nycklar", + "apiKeyDeleteFailed": "Misslyckades med att ta bort API-nyckeln", + "exportUserData": "Dataexport", + "exportUserDataDesc": "Ladda ner den här användarens värdar, inloggningsuppgifter och filhanteringsdata som JSON. Hemligheterna dekrypteras.", + "exportUserDataSuccess": "Användardata exporterad", + "exportUserDataFailed": "Misslyckades med att exportera användardata", + "hostsCount": "{{count}} värdar", + "addHostForUser": "Lägg till värd", + "noHostsForUser": "Den här användaren har inga värdar", + "connectToHost": "Ansluta", + "deleteHostConfirm": "Radera värden \"{{name}}\" som tillhör {{username}}?", + "hostDeletedSuccess": "Värd borttagen", + "hostDeleteFailed": "Misslyckades med att ta bort värden", + "credentialsCount": "{{count}} inloggningsuppgifter", + "addCredentialForUser": "Lägg till autentiseringsuppgifter", + "noCredentialsForUser": "Den här användaren har inga inloggningsuppgifter", + "deleteCredentialConfirm": "Ta bort inloggningsuppgifterna \"{{name}}\" som tillhör {{username}}?", + "credentialDeletedSuccess": "Inloggningsuppgifter raderade", + "credentialDeleteFailed": "Misslyckades med att ta bort inloggningsuppgifter", + "snippetsCount": "{{count}} utdrag", + "addSnippetForUser": "Lägg till utdrag", + "noSnippetsForUser": "Den här användaren har inga utdrag", + "snippetRequiredFields": "Namn och innehåll på utdrag krävs", + "snippetNamePlaceholder": "Namn på utdrag", + "snippetContentPlaceholder": "Kommandoinnehåll", + "snippetFolderPlaceholder": "Mapp (valfritt)", + "snippetSaved": "Utdrag sparat", + "snippetSaveFailed": "Kunde inte spara utdraget", + "deleteSnippetConfirm": "Ta bort utdraget \"{{name}}\" som tillhör {{username}}?", + "snippetDeletedSuccess": "Utdraget raderat", + "snippetDeleteFailed": "Misslyckades med att ta bort utdraget", + "noSessionsForUser": "Inga aktiva sessioner", + "deleteUserDangerDesc": "Ta bort {{username}} och all dess data (värdar, inloggningsuppgifter, kodavsnitt, historik) permanent. Detta kan inte ångras.", + "deleteUserConfirm": "Vill du permanent radera {{username}} och all deras data?", + "deleteUserAdminBlocked": "Ta bort administratörsstatus innan du tar bort den här användaren.", "createRoleRequired": "Namn och visningsnamn krävs", "createRoleSuccess": "Rollen \"{{name}}\" skapades", "createRoleFailed": "Misslyckades med att skapa rollen", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Kommandohistorik", "hostDefaultsCommandHistoryDesc": "Spåra kommandohistorik på nya värdar som standard", "hostDefaultsSaved": "Värdstandardvärden sparade", - "hostDefaultsSaveFailed": "Misslyckades med att spara värdens standardinställningar" + "hostDefaultsSaveFailed": "Misslyckades med att spara värdens standardinställningar", + "rolePermissions": { + "count": "{{count}} behörigheter", + "editAction": "Redigera behörigheter", + "loadError": "Det gick inte att läsa in behörighetskatalogen", + "saved": "Rollbehörigheter sparade", + "saveError": "Kunde inte spara rollbehörigheter", + "save": "Spara", + "saving": "Sparande..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Misslyckades" }, "userProfile": { + "donateTitle": "Håll Termix vid liv", + "donateDescription": "Termix är gratis, självhostat och byggt av endast ett fåtal personer på sin fritid. Om det har sparat dig tid eller pengar, hjälper en kryptodonation till att hålla det igång.", + "donateMilestones": "Donationer hjälper till att finansiera tiden för att undersöka och lära sig vad som behövs för att bygga stöd för SAML, Kubernetes och agenter. Se framstegen och donera.", + "donateButton": "Donera krypto", "storageModeLocal": "Webbläsare", "storageModeCloud": "Databas", "storageModeDescription": "Webbläsaren lagrar inställningar endast i den här webbläsaren. Databasen synkroniseras med servern och laddas på valfri enhet.", @@ -3011,6 +3144,10 @@ "twoFaOn": "På", "twoFaOff": "Av", "versionLabel": "Version", + "betaProgramTitle": "Betaprogram", + "betaProgramDescription": "Testa kommande funktioner tidigt med den veckovisa rullande :beta Docker-taggen. Instabil, inte för produktion.", + "betaProgramFeedback": "Hittat ett fel? Rapportera det här.", + "betaProgramLearnMore": "Läs mer", "deleteAccount": "Ta bort konto", "deleteAccountDescription": "Ta bort ditt konto permanent", "changeServerDescription": "Byt till en annan Termix-backendserver", diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index fe66e474..6a99c25a 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "รูปแบบชื่อที่คั่นด้วยเครื่องหมายจุลภาค ซึ่งช่วยให้ Docker สามารถจับคู่กับเครื่องเสมือนได้", "proxmoxPreferredRanges": "ช่วง IP ที่ต้องการ", "proxmoxPreferredRangesDesc": "คำนำหน้า IP ที่คั่นด้วยเครื่องหมายจุลภาคเรียงตามลำดับความสำคัญสำหรับการเลือก IP เมื่อผู้ใช้งานมีอินเทอร์เฟซหลายตัว", + "proxmoxAutoSync": "ซิงค์ข้อมูลแขกโดยอัตโนมัติ", + "proxmoxAutoSyncDesc": "ตรวจสอบโหนด Proxmox นี้เป็นระยะ และสร้างหรืออัปเดตโฮสต์แขกที่นำเข้าในขณะที่เซสชันของคุณไม่ได้ล็อกอยู่", + "proxmoxSyncInterval": "ช่วงเวลาการซิงค์ (นาที)", + "proxmoxSyncIntervalDesc": "อย่างน้อย 5 นาที ระบบกำหนดเวลาจะข้ามเซสชันของผู้ใช้ที่ถูกล็อกไว้", + "proxmoxMarkMissing": "ทำเครื่องหมายแขกที่ขาดหายไป", + "proxmoxMarkMissingDesc": "เพิ่มแท็ก proxmox-missing เมื่อเครื่องเสมือนที่นำเข้าก่อนหน้านี้หายไป แทนที่จะลบออก", + "proxmoxLastSync": "การซิงค์ครั้งล่าสุด", + "proxmoxLastSyncNever": "ยังไม่ได้ซิงค์ข้อมูล", + "proxmoxLastSyncNoResult": "ไม่มีรายละเอียดผลลัพธ์", + "proxmoxLastSyncSummary": "{{created}} ถูกสร้างขึ้น, {{updated}} อัปเดตแล้ว, {{markedMissing}} หายไป, {{skipped}} ข้ามไป", + "proxmoxLastSyncStatus": { + "success": "ความสำเร็จ", + "error": "ล้มเหลว", + "pending": "รอดำเนินการ" + }, "proxmoxDiscoverAction": "ค้นหาและนำเข้าแขกของ Proxmox", "proxmoxImportTitle": "นำเข้าจาก Proxmox", "proxmoxSelectHost": "เลือกโฮสต์ Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "แชร์โฮสต์", "shareHostTitle": "แชร์: {{name}}", "sharing": { - "requiresCredential": "โฮสต์นี้ต้องใช้ข้อมูลรับรองเพื่อเปิดใช้งานการแชร์ แก้ไขข้อมูลโฮสต์และกำหนดข้อมูลรับรองก่อน" + "loadError": "ไม่สามารถโหลดข้อมูลการแชร์ได้ โปรดลองอีกครั้ง", + "shareWithSection": "แบ่งปันกับ", + "usersTab": "ผู้ใช้", + "rolesTab": "บทบาท", + "searchPlaceholder": "ค้นหาผู้ใช้หรือบทบาท...", + "noMatches": "ไม่พบผลลัพธ์ที่ตรงกัน", + "permissionLevelLabel": "ระดับการอนุญาต", + "levels": { + "connect": { + "label": "เชื่อมต่อ", + "description": "อนุญาตเฉพาะเซสชันที่เปิดใช้งานเท่านั้น: เทอร์มินัล, เดสก์ท็อประยะไกล, ตัวจัดการไฟล์, อุโมงค์ และ Docker ไม่สามารถเข้าถึงการกำหนดค่าโฮสต์ได้" + }, + "view": { + "label": "ดู", + "description": "เชื่อมต่อและดูการตั้งค่าโฮสต์ ข้อมูลลับจะไม่ถูกเปิดเผย" + }, + "edit": { + "label": "แก้ไข", + "description": "ดูและแก้ไขข้อมูลโฮสต์ได้ สามารถเปลี่ยนรหัสลับได้ แต่ไม่สามารถอ่านได้ การกำหนดสิทธิ์การเข้าถึงจะสงวนไว้เฉพาะเจ้าของเท่านั้น" + }, + "manage": { + "label": "จัดการ", + "description": "แก้ไข รวมถึงแชร์โฮสต์กับผู้อื่น เปลี่ยนระดับสิทธิ์ และเพิกถอนการเข้าถึงได้" + } + }, + "expiryLabel": "วันหมดอายุการเข้าถึง", + "expiry": { + "never": "ไม่เคย", + "oneHour": "1 ชั่วโมง", + "oneDay": "24 ชั่วโมง", + "sevenDays": "7 วัน", + "thirtyDays": "30 วัน", + "custom": "กำหนดเอง" + }, + "customHoursPlaceholder": "เหลือเวลาอีกกี่ชั่วโมงก่อนที่สิทธิ์การเข้าถึงจะหมดอายุ", + "shareButton": "แบ่งปัน", + "shareWithCount": "แชร์ ({{count}})", + "currentAccess": "การเข้าถึงปัจจุบัน", + "noAccessEntries": "โฮสต์นี้ยังไม่ได้ถูกแชร์", + "grantedBy": "ได้รับอนุญาตจาก", + "expires": "หมดอายุ", + "expired": "หมดอายุ", + "never": "ไม่เคย", + "revoke": "ถอน", + "accessUpdated": "การเข้าถึงได้รับการอัปเดตแล้ว", + "accessUpdateFailed": "ไม่สามารถอัปเดตการเข้าถึงได้", + "sharedBadge": "แชร์", + "sharedBadgeTooltip": "แชร์โดย {{owner}} ({{level}} เข้าถึงได้)", + "viewOnlyBanner": "โฮสต์นี้ถูกแชร์กับคุณโดย {{owner}} โดยมีสิทธิ์ในการดู การตั้งค่าเป็นแบบอ่านอย่างเดียว", + "sharedEditBanner": "โฮสต์นี้ถูกแชร์กับคุณโดย {{owner}} โดยมีสิทธิ์ในการแก้ไข การเปลี่ยนแปลงจะมีผลกับโฮสต์จริงเท่านั้น ข้อมูลอ้างอิงการตรวจสอบสิทธิ์สามารถเปลี่ยนแปลงได้โดยเจ้าของเท่านั้น", + "ownerOnlyControl": "เฉพาะเจ้าของโฮสต์เท่านั้นที่สามารถเปลี่ยนแปลงสิ่งนี้ได้" }, "guac": { "connection": "การเชื่อมต่อ", @@ -1302,28 +1367,10 @@ "backspaceKey": "ปุ่มลบ", "saveHostFirst": "บันทึกข้อมูลโฮสต์ก่อน", "sharingOptionsAfterSave": "ตัวเลือกการแชร์จะพร้อมใช้งานหลังจากบันทึกโฮสต์แล้ว", - "sharingLoadError": "ไม่สามารถโหลดข้อมูลการแชร์ได้ โปรดตรวจสอบการเชื่อมต่อของคุณแล้วลองใหม่อีกครั้ง", - "shareHostSection": "แชร์โฮสต์", - "shareWithUser": "แชร์กับผู้ใช้", - "shareWithRole": "แชร์กับบทบาท", - "selectUser": "เลือกผู้ใช้", - "selectRole": "เลือกบทบาท", - "selectUserOption": "เลือกผู้ใช้...", - "selectRoleOption": "เลือกบทบาท...", "permissionLevel": "ระดับการอนุญาต", - "expiresInHours": "หมดอายุใน (ชั่วโมง)", - "noExpiryPlaceholder": "เว้นว่างไว้เพื่อป้องกันการหมดอายุ", - "shareBtn": "แบ่งปัน", - "currentAccess": "การเข้าถึงปัจจุบัน", "typeHeader": "พิมพ์", "targetHeader": "เป้า", "permissionHeader": "การอนุญาต", - "grantedByHeader": "ได้รับอนุญาตโดย", - "expiresHeader": "หมดอายุ", - "noAccessEntries": "ยังไม่มีข้อมูลการเข้าถึงใดๆ", - "expiredLabel": "หมดอายุ", - "neverLabel": "ไม่เคย", - "revokeBtn": "ถอน", "cancelBtn": "ยกเลิก", "savingBtn": "ประหยัด...", "updateHostBtn": "อัปเดตโฮสต์", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "การเข้าสู่ระบบด้วยรหัสผ่านถูกปิดใช้งาน โปรดใช้รหัสผ่านเฉพาะหรือผู้ให้บริการตรวจสอบสิทธิ์ภายนอก", "signInWithPasskey": "ลงชื่อเข้าใช้ด้วยรหัสผ่าน", "passkeyLoginFailed": "การเข้าสู่ระบบด้วยรหัสผ่านล้มเหลว", - "attemptsRemaining": "เหลือการลองอีก {{count}} ครั้ง" + "attemptsRemaining": "เหลือการลองอีก {{count}} ครั้ง", + "confirmResetDataWipe": "บัญชีนี้ไม่ได้เข้าสู่ระบบตั้งแต่การอัปเกรดการเข้ารหัส ดังนั้นข้อมูลที่จัดเก็บไว้จึงไม่สามารถกู้คืนได้หากไม่มีรหัสผ่านเดิม การรีเซ็ตจะลบโฮสต์ ข้อมูลประจำตัว และส่วนย่อยของบัญชีนี้อย่างถาวร ต้องการดำเนินการต่อหรือไม่?" }, "hostKey": { "verifyNewHost": "ตรวจสอบคีย์โฮสต์ SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "ลิงก์บริการ", "homepagePreview": "ตัวอย่างหน้าแรก" }, + "donation": { + "title": "ชอบ Termix ไหม?", + "body": "Termix เป็นซอฟต์แวร์โอเพนซอร์สฟรี สร้างและดูแลโดยทีมงานสองคนในเวลาว่าง หาก Termix เข้ามาแทนที่เครื่องมือเชิงพาณิชย์ที่คุณต้องจ่ายเงินอยู่แล้ว การบริจาคจะช่วยครอบคลุมค่าใช้จ่ายในการโฮสติ้งและพัฒนาต่อไป ปัจจุบันรับบริจาคเฉพาะคริปโตเคอร์เรนซีเท่านั้น", + "milestones": "เงินบริจาคยังช่วยสนับสนุนเวลาในการวิจัยและเรียนรู้สิ่งที่จำเป็นในการสร้างฟีเจอร์ต่างๆ เช่น SAML และการรองรับ Kubernetes ดูความคืบหน้าได้ที่หน้าบริจาค", + "cta": "บริจาค", + "dismiss": "อาจจะทีหลัง" + }, "dashboardTab": { "stable": "มั่นคง", "hostsOnline": "โฮสต์ออนไลน์", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "ไม่สามารถอัปเดตสถานะผู้ดูแลระบบได้", "allSessionsRevoked": "เซสชันทั้งหมดถูกยกเลิก", "revokeSessionsFailed": "ไม่สามารถยกเลิกเซสชันได้", + "manageUserData": "จัดการข้อมูลผู้ใช้", + "backToUsers": "กลับสู่หน้าผู้ใช้", + "manageTabAccount": "บัญชี", + "manageTabHosts": "โฮสต์", + "manageTabCredentials": "คุณสมบัติ", + "manageTabSnippets": "เศษเสี้ยว", + "manageTabSessions": "การประชุม", + "manageTabDanger": "อันตราย", + "manageEditorBack": "กลับไปที่ข้อมูลของ {{username}}", + "dataLockedBadge": "ล็อกอยู่", + "dataLockedNotice": "ข้อมูลของ {{username}}จะถูกล็อกไว้จนกว่าพวกเขาจะเข้าสู่ระบบครั้งถัดไป จะไม่สามารถดูหรือแก้ไขโฮสต์ ข้อมูลประจำตัว และข้อความย่อของพวกเขาได้จนกว่าจะถึงเวลานั้น", + "resetPasswordTitle": "รีเซ็ตรหัสผ่าน", + "resetPasswordOidcOnly": "ผู้ใช้รายนี้เข้าสู่ระบบผ่านผู้ให้บริการภายนอกและไม่มีรหัสผ่าน", + "resetPasswordPlaceholder": "รหัสผ่านใหม่", + "resetPasswordBtn": "รีเซ็ต", + "resetPasswordWorking": "กำลังรีเซ็ต...", + "resetPasswordSuccess": "รีเซ็ตรหัสผ่าน", + "resetPasswordSuccessWiped": "รีเซ็ตรหัสผ่าน ข้อมูลที่เข้ารหัสของผู้ใช้ถูกลบออกแล้ว", + "resetPasswordFailed": "ไม่สามารถรีเซ็ตรหัสผ่านได้", + "resetPasswordConfirmWipe": "ผู้ใช้ {{username}} ไม่ได้ล็อกอินเข้าสู่ระบบตั้งแต่การอัปเกรดการเข้ารหัส ดังนั้นจึงไม่สามารถกู้คืนข้อมูลได้ การรีเซ็ตในขณะนี้จะลบโฮสต์ ข้อมูลประจำตัว และข้อมูลย่อของผู้ใช้ดังกล่าว ต้องการดำเนินการต่อหรือไม่?", + "totpSectionTitle": "การตรวจสอบสิทธิ์แบบสองปัจจัย", + "totpStatusEnabled": "TOTP เปิดใช้งานสำหรับผู้ใช้รายนี้แล้ว", + "totpStatusDisabled": "TOTP ไม่ได้เปิดใช้งานสำหรับผู้ใช้รายนี้", + "disableTotp": "ปิดใช้งาน", + "disableTotpConfirm": "ปิดใช้งานการตรวจสอบสิทธิ์แบบสองขั้นตอนสำหรับ {{username}}หรือไม่? พวกเขาจะสามารถเข้าสู่ระบบได้โดยใช้เพียงรหัสผ่านเท่านั้น", + "totpDisabledSuccess": "TOTP ถูกปิดใช้งาน", + "totpDisableFailed": "ไม่สามารถปิดใช้งาน TOTP ได้", + "manageApiKeys": "คีย์ API", + "apiKeyNamePlaceholder": "ชื่อหลัก", + "apiKeyCopyNotice": "คัดลอกรหัสนี้ไว้ ตอนนี้จะไม่ปรากฏอีกแล้ว", + "noApiKeysForUser": "ไม่มีคีย์ API", + "apiKeyDeleteFailed": "ไม่สามารถลบคีย์ API ได้", + "exportUserData": "การส่งออกข้อมูล", + "exportUserDataDesc": "ดาวน์โหลดข้อมูลโฮสต์ ข้อมูลประจำตัว และข้อมูลตัวจัดการไฟล์ของผู้ใช้รายนี้ในรูปแบบ JSON ข้อมูลลับจะถูกถอดรหัส", + "exportUserDataSuccess": "ข้อมูลผู้ใช้ที่ส่งออก", + "exportUserDataFailed": "ไม่สามารถส่งออกข้อมูลผู้ใช้ได้", + "hostsCount": "{{count}} โฮสต์", + "addHostForUser": "เพิ่มโฮสต์", + "noHostsForUser": "ผู้ใช้รายนี้ไม่มีโฮสต์", + "connectToHost": "เชื่อมต่อ", + "deleteHostConfirm": "ลบโฮสต์ \"{{name}}\" ที่เป็นของ {{username}} หรือไม่?", + "hostDeletedSuccess": "โฮสต์ถูกลบ", + "hostDeleteFailed": "ไม่สามารถลบโฮสต์ได้", + "credentialsCount": "{{count}} ข้อมูลประจำตัว", + "addCredentialForUser": "เพิ่มข้อมูลรับรอง", + "noCredentialsForUser": "ผู้ใช้รายนี้ไม่มีสิทธิ์เข้าสู่ระบบ", + "deleteCredentialConfirm": "ลบข้อมูลประจำตัว \"{{name}}\" ที่เป็นของ {{username}} หรือไม่?", + "credentialDeletedSuccess": "ข้อมูลประจำตัวถูกลบแล้ว", + "credentialDeleteFailed": "ไม่สามารถลบข้อมูลประจำตัวได้", + "snippetsCount": "{{count}} ตัวอย่างข้อความ", + "addSnippetForUser": "เพิ่มโค้ดตัวอย่าง", + "noSnippetsForUser": "ผู้ใช้รายนี้ไม่มีตัวอย่างโค้ด", + "snippetRequiredFields": "จำเป็นต้องระบุชื่อและเนื้อหาของโค้ดตัวอย่าง", + "snippetNamePlaceholder": "ชื่อส่วนย่อย", + "snippetContentPlaceholder": "เนื้อหาคำสั่ง", + "snippetFolderPlaceholder": "โฟลเดอร์ (ไม่จำเป็น)", + "snippetSaved": "บันทึกส่วนย่อยแล้ว", + "snippetSaveFailed": "ไม่สามารถบันทึกส่วนย่อยได้", + "deleteSnippetConfirm": "ลบส่วนย่อย \"{{name}}\" ที่เป็นของ {{username}} หรือไม่?", + "snippetDeletedSuccess": "ข้อความบางส่วนถูกลบแล้ว", + "snippetDeleteFailed": "ไม่สามารถลบส่วนย่อยได้", + "noSessionsForUser": "ไม่มีเซสชันที่ใช้งานอยู่", + "deleteUserDangerDesc": "ลบ {{username}} และข้อมูลทั้งหมด (โฮสต์ ข้อมูลรับรอง ตัวอย่างโค้ด ประวัติ) อย่างถาวร การดำเนินการนี้ไม่สามารถย้อนกลับได้", + "deleteUserConfirm": "ลบ {{username}} และข้อมูลทั้งหมดของพวกเขาอย่างถาวร?", + "deleteUserAdminBlocked": "กรุณาลบสิทธิ์ผู้ดูแลระบบก่อนลบผู้ใช้รายนี้", "createRoleRequired": "ต้องระบุชื่อและชื่อที่แสดง", "createRoleSuccess": "บทบาท \"{{name}}\" ถูกสร้างขึ้น", "createRoleFailed": "ไม่สามารถสร้างบทบาทได้", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "ประวัติคำสั่ง", "hostDefaultsCommandHistoryDesc": "โดยค่าเริ่มต้น จะมีการบันทึกประวัติคำสั่งบนโฮสต์ใหม่", "hostDefaultsSaved": "บันทึกค่าเริ่มต้นของโฮสต์แล้ว", - "hostDefaultsSaveFailed": "ไม่สามารถบันทึกค่าเริ่มต้นของโฮสต์ได้" + "hostDefaultsSaveFailed": "ไม่สามารถบันทึกค่าเริ่มต้นของโฮสต์ได้", + "rolePermissions": { + "count": "สิทธิ์ {{count}}", + "editAction": "แก้ไขสิทธิ์การเข้าถึง", + "loadError": "ไม่สามารถโหลดแคตตาล็อกสิทธิ์ได้", + "saved": "บันทึกสิทธิ์การใช้งานบทบาทแล้ว", + "saveError": "ไม่สามารถบันทึกสิทธิ์การเข้าถึงบทบาทได้", + "save": "บันทึก", + "saving": "ประหยัด..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "ล้มเหลว" }, "userProfile": { + "donateTitle": "รักษา Termix ให้คงอยู่ต่อไป", + "donateDescription": "Termix เป็นโปรแกรมฟรี ติดตั้งบนเซิร์ฟเวอร์ของคุณเอง และสร้างขึ้นโดยคนเพียงไม่กี่คนในเวลาว่าง หากโปรแกรมนี้ช่วยประหยัดเวลาหรือเงินของคุณ การบริจาคด้วยคริปโตเคอร์เรนซีจะช่วยให้โปรแกรมนี้ดำเนินต่อไปได้", + "donateMilestones": "เงินบริจาคช่วยสนับสนุนเวลาในการวิจัยและเรียนรู้สิ่งที่จำเป็นในการสร้าง SAML, Kubernetes และ Agent ดูความคืบหน้าและบริจาคได้ที่นี่", + "donateButton": "บริจาคคริปโต", "storageModeLocal": "เบราว์เซอร์", "storageModeCloud": "ฐานข้อมูล", "storageModeDescription": "เบราว์เซอร์จะบันทึกการตั้งค่าไว้ในเบราว์เซอร์นี้เท่านั้น ฐานข้อมูลจะซิงค์กับเซิร์ฟเวอร์และโหลดได้บนทุกอุปกรณ์", @@ -3011,6 +3144,10 @@ "twoFaOn": "บน", "twoFaOff": "ปิด", "versionLabel": "เวอร์ชั่น", + "betaProgramTitle": "โปรแกรมเบต้า", + "betaProgramDescription": "ทดลองใช้ฟีเจอร์ใหม่ ๆ ก่อนใครด้วยแท็ก Docker :beta ที่มีการอัปเดตทุกสัปดาห์ ยังไม่เสถียร ไม่เหมาะสำหรับใช้งานจริง", + "betaProgramFeedback": "พบข้อผิดพลาดใช่ไหม? รายงานได้ที่นี่", + "betaProgramLearnMore": "เรียนรู้เพิ่มเติม", "deleteAccount": "ลบบัญชีผู้ใช้", "deleteAccountDescription": "ลบบัญชีของคุณอย่างถาวร", "changeServerDescription": "เปลี่ยนไปใช้เซิร์ฟเวอร์แบ็กเอนด์ Termix อื่น", diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index 84d13d69..4b384670 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Docker'ın konukları eşleştirmesini sağlayan virgülle ayrılmış ad kalıpları.", "proxmoxPreferredRanges": "Tercih edilen IP aralıkları", "proxmoxPreferredRangesDesc": "Misafir işletim sisteminin birden fazla arayüzü olduğunda IP seçimi için öncelik sırasına göre virgülle ayrılmış ön ekler.", + "proxmoxAutoSync": "Konukları otomatik olarak senkronize et", + "proxmoxAutoSyncDesc": "Oturumunuzun kilidi açıkken, periyodik olarak bu Proxmox düğümünü keşfedin ve içe aktarılan konuk sunucuları oluşturun veya güncelleyin.", + "proxmoxSyncInterval": "Senkronizasyon aralığı (dakika)", + "proxmoxSyncIntervalDesc": "Minimum 5 dakika. Zamanlayıcı, kilitli kullanıcı oturumlarını atlar.", + "proxmoxMarkMissing": "Mark'ın kayıp misafirleri", + "proxmoxMarkMissingDesc": "Daha önce içe aktarılan bir konuk işletim sistemi kaybolduğunda, onu silmek yerine proxmox-missing etiketi ekleyin.", + "proxmoxLastSync": "Son senkronizasyon", + "proxmoxLastSyncNever": "Henüz senkronize edilmedi", + "proxmoxLastSyncNoResult": "Sonuç bilgisi yok.", + "proxmoxLastSyncSummary": "{{created}} oluşturuldu, {{updated}} güncellendi, {{markedMissing}} eksik, {{skipped}} atlandı", + "proxmoxLastSyncStatus": { + "success": "Başarı", + "error": "Arızalı", + "pending": "Askıda olması" + }, "proxmoxDiscoverAction": "Proxmox misafirlerini keşfedin ve içe aktarın", "proxmoxImportTitle": "Proxmox'tan içe aktar", "proxmoxSelectHost": "Bir Proxmox sunucusu seçin…", @@ -1167,7 +1182,57 @@ "shareHost": "Paylaşımlı Sunucu", "shareHostTitle": "Paylaş: {{name}}", "sharing": { - "requiresCredential": "Bu sunucunun paylaşımı etkinleştirmek için bir kimlik bilgisi kullanması gerekir. Öncelikle sunucuyu düzenleyin ve bir kimlik bilgisi atayın." + "loadError": "Paylaşım verileri yüklenemedi. Lütfen tekrar deneyin.", + "shareWithSection": "Şununla paylaş", + "usersTab": "Kullanıcılar", + "rolesTab": "Roller", + "searchPlaceholder": "Kullanıcıları veya rolleri ara...", + "noMatches": "Eşleşme bulunamadı.", + "permissionLevelLabel": "İzin seviyesi", + "levels": { + "connect": { + "label": "Bağlamak", + "description": "Yalnızca açık oturumlar: terminal, uzak masaüstü, dosya yöneticisi, tüneller ve Docker. Sunucu yapılandırmasına erişim yok." + }, + "view": { + "label": "Görüş", + "description": "Bağlanın ve sunucu yapılandırmasını görün. Gizli bilgiler asla gösterilmez." + }, + "edit": { + "label": "Düzenlemek", + "description": "Sunucuyu görüntüleyin ve değiştirin. Gizli bilgiler değiştirilebilir ancak asla okunamaz; kimlik bilgisi atamaları yalnızca sahibine özel kalır." + }, + "manage": { + "label": "Üstesinden gelmek", + "description": "Sunucuyu düzenleyin, başkalarıyla paylaşın, izin seviyelerini değiştirin ve erişimi iptal edin." + } + }, + "expiryLabel": "Erişim süresinin dolması", + "expiry": { + "never": "Asla", + "oneHour": "1 saat", + "oneDay": "24 saat", + "sevenDays": "7 gün", + "thirtyDays": "30 gün", + "custom": "Gelenek" + }, + "customHoursPlaceholder": "Erişim süresinin dolmasına kalan saatler", + "shareButton": "Paylaşmak", + "shareWithCount": "Paylaş ({{count}})", + "currentAccess": "Mevcut erişim", + "noAccessEntries": "Bu sunucu henüz paylaşılmadı.", + "grantedBy": "tarafından verilmiştir.", + "expires": "Süresi doluyor", + "expired": "Günü geçmiş", + "never": "Asla", + "revoke": "Geri çekmek", + "accessUpdated": "Güncellenmiş erişim", + "accessUpdateFailed": "Erişim bilgilerini güncelleme başarısız oldu.", + "sharedBadge": "Paylaşıldı", + "sharedBadgeTooltip": "{{owner}} tarafından paylaşıldı ({{level}} erişim)", + "viewOnlyBanner": "Bu sunucu, {{owner}} tarafından sizinle görüntüleme erişimiyle paylaşılıyor. Yapılandırma yalnızca okunabilir durumdadır.", + "sharedEditBanner": "Bu sunucu, düzenleme erişimiyle {{owner}} tarafından sizinle paylaşılmaktadır. Değişiklikler gerçek sunucuya uygulanır; kimlik doğrulama referansları yalnızca sahibi tarafından değiştirilebilir.", + "ownerOnlyControl": "Bunu yalnızca ev sahibi değiştirebilir." }, "guac": { "connection": "Bağlantı", @@ -1302,28 +1367,10 @@ "backspaceKey": "Geri tuşu", "saveHostFirst": "Önce sunucuyu kurtarın.", "sharingOptionsAfterSave": "Sunucu kaydedildikten sonra paylaşım seçenekleri kullanılabilir hale gelir.", - "sharingLoadError": "Paylaşım verileri yüklenemedi. Bağlantınızı kontrol edin ve tekrar deneyin.", - "shareHostSection": "Paylaşımlı Sunucu", - "shareWithUser": "Kullanıcıyla paylaş", - "shareWithRole": "Rol ile paylaş", - "selectUser": "Kullanıcı Seç", - "selectRole": "Rol Seçin", - "selectUserOption": "Bir kullanıcı seçin...", - "selectRoleOption": "Bir rol seçin...", "permissionLevel": "İzin Seviyesi", - "expiresInHours": "(Saat) içinde sona eriyor", - "noExpiryPlaceholder": "Son kullanma tarihi olmaması için boş bırakın.", - "shareBtn": "Paylaşmak", - "currentAccess": "Mevcut Erişim", "typeHeader": "Tip", "targetHeader": "Hedef", "permissionHeader": "İzin", - "grantedByHeader": "Veren", - "expiresHeader": "Süresi doluyor", - "noAccessEntries": "Henüz erişim kaydı yok.", - "expiredLabel": "Günü geçmiş", - "neverLabel": "Asla", - "revokeBtn": "Geri çekmek", "cancelBtn": "İptal etmek", "savingBtn": "Tasarruf...", "updateHostBtn": "Sunucuyu Güncelle", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Parola ile giriş devre dışı bırakılmıştır. Lütfen bir parola anahtarı veya harici bir kimlik doğrulama sağlayıcısı kullanın.", "signInWithPasskey": "Parola ile giriş yapın", "passkeyLoginFailed": "Parola girişi başarısız oldu.", - "attemptsRemaining": "{{count}} deneme kaldı" + "attemptsRemaining": "{{count}} deneme kaldı", + "confirmResetDataWipe": "Şifreleme güncellemesinden bu yana bu hesaba giriş yapılmadı, bu nedenle eski parola olmadan saklanan veriler kurtarılamaz. Sıfırlama işlemi, ana bilgisayarlarını, kimlik bilgilerini ve kod parçacıklarını kalıcı olarak silecektir. Devam edilsin mi?" }, "hostKey": { "verifyNewHost": "SSH Ana Bilgisayar Anahtarını Doğrulayın", @@ -2323,6 +2371,13 @@ "serviceLinks": "Hizmet Bağlantıları", "homepagePreview": "Ana Sayfa Önizlemesi" }, + "donation": { + "title": "Termix'ten keyif alıyor musunuz?", + "body": "Termix, boş zamanlarımızda iki kişilik bir ekip tarafından geliştirilen ve sürdürülen ücretsiz ve açık kaynaklı bir platformdur. Eğer daha önce ücretli olarak kullandığınız ticari bir aracın yerini aldıysa, bağışlarınız barındırma maliyetlerini karşılamaya ve geliştirmenin devam etmesine yardımcı olur. Şu an itibariyle bağışlar yalnızca kripto para birimleriyle kabul edilmektedir.", + "milestones": "Bağışlar ayrıca SAML ve Kubernetes desteği gibi özelliklerin geliştirilmesi için gereken araştırma ve öğrenme süreçlerine harcanan zamana da katkı sağlıyor. Bağış sayfasından gelişmeler hakkında bilgi edinebilirsiniz.", + "cta": "Bağış yapmak", + "dismiss": "Belki daha sonra" + }, "dashboardTab": { "stable": "STABİL", "hostsOnline": "Çevrimiçi Sunucular", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Yönetici durumunu güncelleme başarısız oldu.", "allSessionsRevoked": "Tüm oturumlar iptal edildi.", "revokeSessionsFailed": "Oturumları iptal etme başarısız oldu.", + "manageUserData": "Kullanıcı verilerini yönetin", + "backToUsers": "Kullanıcılara geri dön", + "manageTabAccount": "Hesap", + "manageTabHosts": "Ev sahipleri", + "manageTabCredentials": "Kimlik Bilgileri", + "manageTabSnippets": "Kısa bölümler", + "manageTabSessions": "Oturumlar", + "manageTabDanger": "Tehlike", + "manageEditorBack": "{{username}}'ın verilerine geri dön", + "dataLockedBadge": "KİLİTLİ", + "dataLockedNotice": "{{username}}'ın verileri, bir sonraki girişlerine kadar kilitli kalır. O zamana kadar sunucuları, kimlik bilgileri ve kod parçacıkları görüntülenemez veya düzenlenemez.", + "resetPasswordTitle": "Şifreyi Sıfırla", + "resetPasswordOidcOnly": "Bu kullanıcı harici bir sağlayıcı aracılığıyla giriş yapıyor ve şifresi yok.", + "resetPasswordPlaceholder": "Yeni Şifre", + "resetPasswordBtn": "Sıfırla", + "resetPasswordWorking": "Sıfırlanıyor...", + "resetPasswordSuccess": "Şifre sıfırlama", + "resetPasswordSuccessWiped": "Parola sıfırlama. Kullanıcının şifrelenmiş verileri silindi.", + "resetPasswordFailed": "Parola sıfırlama başarısız oldu.", + "resetPasswordConfirmWipe": "{{username}} şifreleme yükseltmesinden beri giriş yapmadı, bu nedenle verileri kurtarılamaz. Şimdi sıfırlama işlemi, ana bilgisayarlarını, kimlik bilgilerini ve kod parçacıklarını silecektir. Devam edilsin mi?", + "totpSectionTitle": "İki Faktörlü Kimlik Doğrulama", + "totpStatusEnabled": "Bu kullanıcı için TOTP etkinleştirilmiştir.", + "totpStatusDisabled": "Bu kullanıcı için TOTP etkinleştirilmemiş.", + "disableTotp": "Devre dışı bırakmak", + "disableTotpConfirm": "{{username}}için iki faktörlü kimlik doğrulamayı devre dışı bırak? Sadece şifreleriyle giriş yapabilecekler.", + "totpDisabledSuccess": "TOTP devre dışı bırakıldı", + "totpDisableFailed": "TOTP devre dışı bırakılamadı.", + "manageApiKeys": "API Anahtarları", + "apiKeyNamePlaceholder": "Anahtar adı", + "apiKeyCopyNotice": "Bu anahtarı şimdi kopyalayın, bir daha gösterilmeyecek.", + "noApiKeysForUser": "API anahtarı yok", + "apiKeyDeleteFailed": "API anahtarını silme işlemi başarısız oldu.", + "exportUserData": "Veri Dışa Aktarımı", + "exportUserDataDesc": "Bu kullanıcının sunucularını, kimlik bilgilerini ve dosya yöneticisi verilerini JSON formatında indirin. Gizli bilgiler şifresi çözülmüştür.", + "exportUserDataSuccess": "Kullanıcı verileri dışa aktarıldı", + "exportUserDataFailed": "Kullanıcı verilerini dışa aktarma başarısız oldu.", + "hostsCount": "{{count}} sunucular", + "addHostForUser": "Sunucu Ekle", + "noHostsForUser": "Bu kullanıcının hiç sunucusu yok.", + "connectToHost": "Bağlamak", + "deleteHostConfirm": "{{username}}'e ait \"{{name}}\" sunucusunu silmek mi istiyorsunuz?", + "hostDeletedSuccess": "Sunucu silindi", + "hostDeleteFailed": "Sunucuyu silme işlemi başarısız oldu.", + "credentialsCount": "{{count}} kimlik bilgileri", + "addCredentialForUser": "Kimlik Bilgisi Ekle", + "noCredentialsForUser": "Bu kullanıcının kimlik bilgileri yok.", + "deleteCredentialConfirm": "{{username}}'e ait \"{{name}}\" kimlik bilgilerini silmek mi istiyorsunuz?", + "credentialDeletedSuccess": "Kimlik bilgisi silindi", + "credentialDeleteFailed": "Kimlik bilgilerini silme işlemi başarısız oldu.", + "snippetsCount": "{{count}} parçalar", + "addSnippetForUser": "Kod Parçası Ekle", + "noSnippetsForUser": "Bu kullanıcının hiç özeti yok.", + "snippetRequiredFields": "Kod parçasının adı ve içeriği zorunludur.", + "snippetNamePlaceholder": "Parça adı", + "snippetContentPlaceholder": "Komut içeriği", + "snippetFolderPlaceholder": "Klasör (isteğe bağlı)", + "snippetSaved": "Kısa bölüm kaydedildi", + "snippetSaveFailed": "Kod parçacığı kaydedilemedi.", + "deleteSnippetConfirm": "{{username}}'e ait \"{{name}}\" kod parçasını silmek mi istiyorsunuz?", + "snippetDeletedSuccess": "Kod parçası silindi", + "snippetDeleteFailed": "Kod parçasını silme işlemi başarısız oldu.", + "noSessionsForUser": "Aktif oturum yok", + "deleteUserDangerDesc": "{{username}} ve tüm verileri (ana bilgisayarlar, kimlik bilgileri, kod parçacıkları, geçmiş) kalıcı olarak silin. Bu işlem geri alınamaz.", + "deleteUserConfirm": "{{username}} ve tüm verilerini kalıcı olarak silmek mi?", + "deleteUserAdminBlocked": "Bu kullanıcıyı silmeden önce yönetici statüsünü kaldırın.", "createRoleRequired": "Ad ve görünen ad zorunludur.", "createRoleSuccess": "\"{{name}}\" rolü oluşturuldu", "createRoleFailed": "Rol oluşturulamadı.", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Komut Geçmişi", "hostDefaultsCommandHistoryDesc": "Yeni sunucularda komut geçmişini varsayılan olarak izle.", "hostDefaultsSaved": "Sunucu varsayılan ayarları kaydedildi.", - "hostDefaultsSaveFailed": "Sunucu varsayılan ayarları kaydedilemedi." + "hostDefaultsSaveFailed": "Sunucu varsayılan ayarları kaydedilemedi.", + "rolePermissions": { + "count": "{{count}} izinler", + "editAction": "Düzenleme izinleri", + "loadError": "İzin kataloğu yüklenemedi.", + "saved": "Rol izinleri kaydedildi.", + "saveError": "Rol izinleri kaydedilemedi.", + "save": "Kaydetmek", + "saving": "Tasarruf..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Arızalı" }, "userProfile": { + "donateTitle": "Termix'i hayatta tutun", + "donateDescription": "Termix ücretsizdir, kendi sunucunuzda barındırır ve sadece birkaç kişi tarafından boş zamanlarında geliştirilmiştir. Eğer size zaman veya para kazandırdıysa, kripto para bağışı projenin devam etmesine yardımcı olur.", + "donateMilestones": "Bağışlar, SAML, Kubernetes ve Agent desteği oluşturmak için gerekenleri araştırmak ve öğrenmek için harcanan zamana katkı sağlıyor. Gelişmeleri takip edin ve bağış yapın.", + "donateButton": "Kripto para bağışı yapın", "storageModeLocal": "Tarayıcı", "storageModeCloud": "Veritabanı", "storageModeDescription": "Tarayıcı ayarları yalnızca bu tarayıcıda saklar. Veritabanı sunucuyla senkronize olur ve herhangi bir cihazda yüklenir.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Açık", "twoFaOff": "Kapalı", "versionLabel": "Sürüm", + "betaProgramTitle": "Beta Programı", + "betaProgramDescription": "Haftalık olarak güncellenen :beta Docker etiketiyle yeni özellikleri erkenden deneyin. Kararsızdır, üretim ortamı için uygun değildir.", + "betaProgramFeedback": "Bir hata mı buldunuz? Buradan bildirin.", + "betaProgramLearnMore": "Daha fazla bilgi edin", "deleteAccount": "Hesabı Sil", "deleteAccountDescription": "Hesabınızı kalıcı olarak silin.", "changeServerDescription": "Farklı bir Termix arka uç sunucusuna geçin.", diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index 04965f7d..c5d5bc08 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Шаблони імен, розділені комами, що дозволяють Docker знаходити гостьові файли", "proxmoxPreferredRanges": "Бажані діапазони IP-адрес", "proxmoxPreferredRangesDesc": "Префікси, розділені комами, у порядку пріоритетності для вибору IP-адреси, коли гость має кілька інтерфейсів", + "proxmoxAutoSync": "Автоматична синхронізація гостей", + "proxmoxAutoSyncDesc": "Періодично виявляйте цей вузол Proxmox та створюйте або оновлюйте імпортовані гостьові хости, поки ваш сеанс розблоковано.", + "proxmoxSyncInterval": "Інтервал синхронізації (хвилини)", + "proxmoxSyncIntervalDesc": "Мінімум 5 хвилин. Планувальник пропускає заблоковані сеанси користувачів.", + "proxmoxMarkMissing": "Позначте відсутніх гостей", + "proxmoxMarkMissingDesc": "Додати тег proxmox-missing, коли раніше імпортований гостьовий сервер зникає, замість його видалення.", + "proxmoxLastSync": "Остання синхронізація", + "proxmoxLastSyncNever": "Ще не синхронізовано", + "proxmoxLastSyncNoResult": "Немає деталей результату", + "proxmoxLastSyncSummary": "{{created}} створено, {{updated}} оновлено, {{markedMissing}} відсутнє, {{skipped}} пропущено", + "proxmoxLastSyncStatus": { + "success": "Успіх", + "error": "Не вдалося", + "pending": "Очікує на розгляд" + }, "proxmoxDiscoverAction": "Пошук та імпорт гостьових систем Proxmox", "proxmoxImportTitle": "Імпорт з Proxmox", "proxmoxSelectHost": "Виберіть хост Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Спільний хост", "shareHostTitle": "Поділитися: {{name}}", "sharing": { - "requiresCredential": "Цей хост повинен використовувати облікові дані, щоб увімкнути спільний доступ. Спочатку відредагуйте хост і призначте облікові дані." + "loadError": "Не вдалося завантажити дані для спільного доступу. Спробуйте ще раз.", + "shareWithSection": "Поділитися з", + "usersTab": "Користувачі", + "rolesTab": "Ролі", + "searchPlaceholder": "Пошук користувачів або ролей...", + "noMatches": "Збігів не знайдено", + "permissionLevelLabel": "Рівень дозволів", + "levels": { + "connect": { + "label": "Підключитися", + "description": "Тільки відкриті сеанси: термінал, віддалений робочий стіл, файловий менеджер, тунелі та Docker. Без доступу до конфігурації хоста." + }, + "view": { + "label": "Переглянути", + "description": "Підключитися, а також переглянути конфігурацію хоста. Секрети ніколи не відображаються." + }, + "edit": { + "label": "Редагувати", + "description": "Перегляд і зміна хоста. Секрети можна замінити, але ніколи не прочитати; призначення облікових даних залишається лише для власника." + }, + "manage": { + "label": "Керувати", + "description": "Редагувати, а також ділитися хостом з іншими, змінювати рівні дозволів та скасовувати доступ." + } + }, + "expiryLabel": "Термін дії доступу", + "expiry": { + "never": "Ніколи", + "oneHour": "1 година", + "oneDay": "24 години", + "sevenDays": "7 днів", + "thirtyDays": "30 днів", + "custom": "Користувацька" + }, + "customHoursPlaceholder": "Години до закінчення терміну дії доступу", + "shareButton": "Поділитися", + "shareWithCount": "Поділитися ({{count}})", + "currentAccess": "Поточний доступ", + "noAccessEntries": "Цей хост ще не був спільним", + "grantedBy": "Надано", + "expires": "Термін дії закінчується", + "expired": "Термін дії минув", + "never": "Ніколи", + "revoke": "Скасувати", + "accessUpdated": "Доступ оновлено", + "accessUpdateFailed": "Не вдалося оновити доступ", + "sharedBadge": "Спільний доступ", + "sharedBadgeTooltip": "Поділився {{owner}} (доступ{{level}})", + "viewOnlyBanner": "Цей хост надано вам спільний доступ до перегляду користувачем {{owner}} . Конфігурація доступна лише для читання.", + "sharedEditBanner": "Цей хост надано вам спільний доступ до редагування користувачем {{owner}} . Зміни застосовуються до справжнього хоста; посилання на автентифікацію може змінити лише власник.", + "ownerOnlyControl": "Тільки власник хоста може це змінити" }, "guac": { "connection": "З'єднання", @@ -1302,28 +1367,10 @@ "backspaceKey": "Клавіша Backspace", "saveHostFirst": "Спочатку збережіть хост.", "sharingOptionsAfterSave": "Опції спільного доступу доступні після збереження хоста.", - "sharingLoadError": "Не вдалося завантажити дані для спільного доступу. Перевірте з’єднання та повторіть спробу.", - "shareHostSection": "Спільний хост", - "shareWithUser": "Поділитися з користувачем", - "shareWithRole": "Поділитися з роллю", - "selectUser": "Виберіть користувача", - "selectRole": "Виберіть роль", - "selectUserOption": "Виберіть користувача...", - "selectRoleOption": "Виберіть роль...", "permissionLevel": "Рівень дозволів", - "expiresInHours": "Термін дії закінчується через (годин)", - "noExpiryPlaceholder": "Залиште порожнім, якщо термін дії не визначено.", - "shareBtn": "Поділитися", - "currentAccess": "Поточний доступ", "typeHeader": "Тип", "targetHeader": "Ціль", "permissionHeader": "Дозвіл", - "grantedByHeader": "Надано", - "expiresHeader": "Термін дії закінчується", - "noAccessEntries": "Записів доступу поки що немає.", - "expiredLabel": "Термін дії минув", - "neverLabel": "Ніколи", - "revokeBtn": "Скасувати", "cancelBtn": "Скасувати", "savingBtn": "Збереження...", "updateHostBtn": "Оновити хост", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Вхід за паролем вимкнено. Використайте ключ доступу або зовнішній постачальник автентифікації.", "signInWithPasskey": "Увійти за допомогою ключа доступу", "passkeyLoginFailed": "Не вдалося ввійти з ключем доступу", - "attemptsRemaining": "Залишилося {{count}} спроб" + "attemptsRemaining": "Залишилося {{count}} спроб", + "confirmResetDataWipe": "Цей обліковий запис не входив у систему з моменту оновлення шифрування, тому його збережені дані неможливо відновити без старого пароля. Скидання налаштувань призведе до остаточного видалення його хостів, облікових даних та фрагментів. Продовжити?" }, "hostKey": { "verifyNewHost": "Перевірте ключ хоста SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Посилання на сервіси", "homepagePreview": "Попередній перегляд головної сторінки" }, + "donation": { + "title": "Подобається Термікс?", + "body": "Termix — це безкоштовний продукт з відкритим кодом, який створює та підтримує команда з двох осіб у вільний час. Якщо він замінює комерційний інструмент, за який ви б інакше платили, пожертва допомагає покрити витрати на хостинг і продовжувати розробку. Наразі пожертви надаються лише у криптовалюті.", + "milestones": "Пожертви також допомагають фінансувати час для дослідження та вивчення того, що потрібно для створення таких функцій, як підтримка SAML та Kubernetes. Слідкуйте за прогресом на сторінці пожертв.", + "cta": "Пожертвувати", + "dismiss": "Можливо, пізніше" + }, "dashboardTab": { "stable": "СТАБІЛЬНИЙ", "hostsOnline": "Хости онлайн", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Не вдалося оновити статус адміністратора", "allSessionsRevoked": "Усі сесії скасовано", "revokeSessionsFailed": "Не вдалося скасувати сеанси", + "manageUserData": "Керування даними користувачів", + "backToUsers": "Назад до користувачів", + "manageTabAccount": "Обліковий запис", + "manageTabHosts": "Хости", + "manageTabCredentials": "Облікові дані", + "manageTabSnippets": "Фрагменти", + "manageTabSessions": "Сесії", + "manageTabDanger": "Небезпека", + "manageEditorBack": "Назад до даних {{username}}", + "dataLockedBadge": "ЗАБЛОКОВАНО", + "dataLockedNotice": "Дані {{username}}залишаються заблокованими до наступного входу в систему. До того часу їхні хости, облікові дані та фрагменти не можна переглядати або редагувати.", + "resetPasswordTitle": "Скинути пароль", + "resetPasswordOidcOnly": "Цей користувач входить через зовнішнього постачальника та не має пароля.", + "resetPasswordPlaceholder": "Новий пароль", + "resetPasswordBtn": "Скинути", + "resetPasswordWorking": "Скидання...", + "resetPasswordSuccess": "Скидання пароля", + "resetPasswordSuccessWiped": "Скидання пароля. Зашифровані дані користувача було видалено.", + "resetPasswordFailed": "Не вдалося скинути пароль", + "resetPasswordConfirmWipe": "{{username}} не входив у систему після оновлення шифрування, тому його дані неможливо відновити. Скидання налаштувань зараз призведе до видалення його хостів, облікових даних та фрагментів. Продовжити?", + "totpSectionTitle": "Двофакторна автентифікація", + "totpStatusEnabled": "TOTP увімкнено для цього користувача", + "totpStatusDisabled": "TOTP не ввімкнено для цього користувача", + "disableTotp": "Вимкнути", + "disableTotpConfirm": "Вимкнути двофакторну автентифікацію для {{username}}? Вони зможуть увійти лише за допомогою пароля.", + "totpDisabledSuccess": "TOTP вимкнено", + "totpDisableFailed": "Не вдалося вимкнути TOTP", + "manageApiKeys": "Ключі API", + "apiKeyNamePlaceholder": "Назва ключа", + "apiKeyCopyNotice": "Скопіюйте цей ключ зараз, він більше не відображатиметься.", + "noApiKeysForUser": "Без ключів API", + "apiKeyDeleteFailed": "Не вдалося видалити ключ API", + "exportUserData": "Експорт даних", + "exportUserDataDesc": "Завантажити хости, облікові дані та дані файлового менеджера цього користувача у форматі JSON. Секрети розшифровано.", + "exportUserDataSuccess": "Експортовано дані користувача", + "exportUserDataFailed": "Не вдалося експортувати дані користувача", + "hostsCount": "{{count}} хости", + "addHostForUser": "Додати хоста", + "noHostsForUser": "Цей користувач не має хостів", + "connectToHost": "Підключитися", + "deleteHostConfirm": "Видалити хост \"{{name}}\", що належить {{username}}?", + "hostDeletedSuccess": "Хост видалено", + "hostDeleteFailed": "Не вдалося видалити хост", + "credentialsCount": "{{count}} облікові дані", + "addCredentialForUser": "Додати облікові дані", + "noCredentialsForUser": "Цей користувач не має облікових даних", + "deleteCredentialConfirm": "Видалити облікові дані \"{{name}}\", що належать {{username}}?", + "credentialDeletedSuccess": "Облікові дані видалено", + "credentialDeleteFailed": "Не вдалося видалити облікові дані", + "snippetsCount": "{{count}} фрагменти", + "addSnippetForUser": "Додати фрагмент", + "noSnippetsForUser": "Цей користувач не має фрагментів", + "snippetRequiredFields": "Назва та вміст фрагмента обов'язкові", + "snippetNamePlaceholder": "Назва фрагмента", + "snippetContentPlaceholder": "Зміст команди", + "snippetFolderPlaceholder": "Папка (необов'язково)", + "snippetSaved": "Фрагмент збережено", + "snippetSaveFailed": "Не вдалося зберегти фрагмент", + "deleteSnippetConfirm": "Видалити фрагмент \"{{name}}\", що належить {{username}}?", + "snippetDeletedSuccess": "Фрагмент видалено", + "snippetDeleteFailed": "Не вдалося видалити фрагмент", + "noSessionsForUser": "Немає активних сеансів", + "deleteUserDangerDesc": "Остаточно видалити {{username}} та всі його дані (хости, облікові дані, фрагменти коду, історію). Цю дію неможливо скасувати.", + "deleteUserConfirm": "Остаточно видалити {{username}} та всі його дані?", + "deleteUserAdminBlocked": "Видаліть статус адміністратора, перш ніж видаляти цього користувача.", "createRoleRequired": "Ім'я та ім'я для відображення обов'язкові", "createRoleSuccess": "Роль «{{name}}» створено", "createRoleFailed": "Не вдалося створити роль", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Історія команд", "hostDefaultsCommandHistoryDesc": "Відстеження історії команд на нових хостах за замовчуванням", "hostDefaultsSaved": "Налаштування хоста за замовчуванням збережено", - "hostDefaultsSaveFailed": "Не вдалося зберегти налаштування хоста за замовчуванням" + "hostDefaultsSaveFailed": "Не вдалося зберегти налаштування хоста за замовчуванням", + "rolePermissions": { + "count": "{{count}} дозволи", + "editAction": "Редагувати дозволи", + "loadError": "Не вдалося завантажити каталог дозволів", + "saved": "Дозволи ролі збережено", + "saveError": "Не вдалося зберегти дозволи ролі", + "save": "Зберегти", + "saving": "Збереження..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Не вдалося" }, "userProfile": { + "donateTitle": "Збережи Термікс живим", + "donateDescription": "Termix безкоштовний, розміщується самостійно та створений лише кількома людьми у вільний час. Якщо це заощадило вам час або гроші, пожертва у криптовалюті допоможе йому продовжувати роботу.", + "donateMilestones": "Пожертви допомагають фінансувати час для дослідження та вивчення того, що потрібно для створення підтримки SAML, Kubernetes та Agent. Слідкуйте за прогресом та робіть пожертви.", + "donateButton": "Пожертвувати криптовалюту", "storageModeLocal": "Браузер", "storageModeCloud": "База даних", "storageModeDescription": "Браузер зберігає налаштування лише в цьому браузері. База даних синхронізується із сервером і завантажується на будь-якому пристрої.", @@ -3011,6 +3144,10 @@ "twoFaOn": "Увімкнено", "twoFaOff": "Вимкнено", "versionLabel": "Версія", + "betaProgramTitle": "Бета-програма", + "betaProgramDescription": "Спробуйте майбутні функції заздалегідь за допомогою щотижневого тегу :beta Docker. Нестабільна версія, не для продакшену.", + "betaProgramFeedback": "Знайшли помилку? Повідомте про неї тут.", + "betaProgramLearnMore": "Дізнатися більше", "deleteAccount": "Видалити обліковий запис", "deleteAccountDescription": "Видалити обліковий запис назавжди", "changeServerDescription": "Переключитися на інший сервер Termix", diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index 5a8a2ef8..1e6f650a 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "Các mẫu tên được phân tách bằng dấu phẩy cho phép Docker khớp với các máy khách.", "proxmoxPreferredRanges": "Dải IP ưu tiên", "proxmoxPreferredRangesDesc": "Các tiền tố được phân tách bằng dấu phẩy theo thứ tự ưu tiên để lựa chọn địa chỉ IP khi khách có nhiều giao diện.", + "proxmoxAutoSync": "Tự động đồng bộ khách", + "proxmoxAutoSyncDesc": "Định kỳ tìm kiếm nút Proxmox này và tạo hoặc cập nhật các máy chủ khách đã nhập trong khi phiên làm việc của bạn đang được mở khóa.", + "proxmoxSyncInterval": "Khoảng thời gian đồng bộ (phút)", + "proxmoxSyncIntervalDesc": "Tối thiểu 5 phút. Trình lập lịch sẽ bỏ qua các phiên người dùng bị khóa.", + "proxmoxMarkMissing": "Đánh dấu những vị khách vắng mặt", + "proxmoxMarkMissingDesc": "Thêm thẻ proxmox-missing khi khách ảo được nhập trước đó biến mất thay vì xóa nó.", + "proxmoxLastSync": "Lần đồng bộ cuối cùng", + "proxmoxLastSyncNever": "Chưa được đồng bộ hóa", + "proxmoxLastSyncNoResult": "Không có thông tin chi tiết về kết quả.", + "proxmoxLastSyncSummary": "{{created}} đã tạo, {{updated}} đã cập nhật, {{markedMissing}} bị thiếu, {{skipped}} đã bỏ qua", + "proxmoxLastSyncStatus": { + "success": "Thành công", + "error": "Thất bại", + "pending": "Chưa giải quyết" + }, "proxmoxDiscoverAction": "Khám phá và nhập khách Proxmox", "proxmoxImportTitle": "Nhập từ Proxmox", "proxmoxSelectHost": "Chọn máy chủ Proxmox…", @@ -1167,7 +1182,57 @@ "shareHost": "Chia sẻ máy chủ", "shareHostTitle": "Chia sẻ: {{name}}", "sharing": { - "requiresCredential": "Máy chủ này phải sử dụng thông tin xác thực để cho phép chia sẻ. Hãy chỉnh sửa máy chủ và gán thông tin xác thực trước." + "loadError": "Không thể tải dữ liệu chia sẻ. Vui lòng thử lại.", + "shareWithSection": "Chia sẻ với", + "usersTab": "Người dùng", + "rolesTab": "Vai trò", + "searchPlaceholder": "Tìm kiếm người dùng hoặc vai trò...", + "noMatches": "Không tìm thấy kết quả phù hợp", + "permissionLevelLabel": "Cấp độ quyền hạn", + "levels": { + "connect": { + "label": "Kết nối", + "description": "Chỉ các phiên mở: terminal, máy tính từ xa, trình quản lý tập tin, đường hầm và Docker. Không có quyền truy cập vào cấu hình máy chủ." + }, + "view": { + "label": "Xem", + "description": "Kết nối và xem cấu hình máy chủ. Thông tin bí mật sẽ không bao giờ được hiển thị." + }, + "edit": { + "label": "Biên tập", + "description": "Xem và chỉnh sửa máy chủ. Có thể thay thế các thông tin bí mật nhưng không bao giờ đọc được; việc gán thông tin xác thực chỉ dành riêng cho chủ sở hữu." + }, + "manage": { + "label": "Quản lý", + "description": "Chỉnh sửa, chia sẻ máy chủ với người khác, thay đổi cấp độ quyền và thu hồi quyền truy cập." + } + }, + "expiryLabel": "Hết hạn truy cập", + "expiry": { + "never": "Không bao giờ", + "oneHour": "1 giờ", + "oneDay": "24 giờ", + "sevenDays": "7 ngày", + "thirtyDays": "30 ngày", + "custom": "Phong tục" + }, + "customHoursPlaceholder": "Còn vài giờ nữa là quyền truy cập hết hạn.", + "shareButton": "Chia sẻ", + "shareWithCount": "Chia sẻ ({{count}})", + "currentAccess": "Quyền truy cập hiện tại", + "noAccessEntries": "Máy chủ này chưa được chia sẻ.", + "grantedBy": "Được cấp bởi", + "expires": "Hết hạn", + "expired": "Hết hạn", + "never": "Không bao giờ", + "revoke": "Thu hồi", + "accessUpdated": "Truy cập thông tin cập nhật", + "accessUpdateFailed": "Không thể cập nhật quyền truy cập", + "sharedBadge": "Chia sẻ", + "sharedBadgeTooltip": "Được chia sẻ bởi {{owner}} (quyền truy cập{{level}})", + "viewOnlyBanner": "Máy chủ này được chia sẻ với bạn bởi {{owner}} với quyền truy cập xem. Cấu hình chỉ có thể đọc.", + "sharedEditBanner": "Máy chủ này được chia sẻ với bạn bởi {{owner}} với quyền chỉnh sửa. Các thay đổi sẽ áp dụng cho máy chủ thực; chỉ chủ sở hữu mới có thể thay đổi các tham chiếu xác thực.", + "ownerOnlyControl": "Chỉ chủ sở hữu máy chủ mới có thể thay đổi điều này." }, "guac": { "connection": "Sự liên quan", @@ -1302,28 +1367,10 @@ "backspaceKey": "Phím xóa lùi", "saveHostFirst": "Hãy lưu máy chủ trước.", "sharingOptionsAfterSave": "Các tùy chọn chia sẻ sẽ khả dụng sau khi máy chủ đã được lưu.", - "sharingLoadError": "Không thể tải dữ liệu chia sẻ. Vui lòng kiểm tra kết nối và thử lại.", - "shareHostSection": "Chia sẻ máy chủ", - "shareWithUser": "Chia sẻ với người dùng", - "shareWithRole": "Chia sẻ với vai trò", - "selectUser": "Chọn người dùng", - "selectRole": "Chọn vai trò", - "selectUserOption": "Chọn người dùng...", - "selectRoleOption": "Chọn một vai trò...", "permissionLevel": "Cấp độ quyền hạn", - "expiresInHours": "Hết hạn sau (giờ)", - "noExpiryPlaceholder": "Để trống cho đến khi hết hạn sử dụng.", - "shareBtn": "Chia sẻ", - "currentAccess": "Truy cập hiện tại", "typeHeader": "Kiểu", "targetHeader": "Mục tiêu", "permissionHeader": "Sự cho phép", - "grantedByHeader": "Được cấp bởi", - "expiresHeader": "Hết hạn", - "noAccessEntries": "Chưa có thông tin đăng nhập nào.", - "expiredLabel": "Hết hạn", - "neverLabel": "Không bao giờ", - "revokeBtn": "Thu hồi", "cancelBtn": "Hủy bỏ", "savingBtn": "Đang lưu...", "updateHostBtn": "Cập nhật máy chủ", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "Chức năng đăng nhập bằng mật khẩu đã bị vô hiệu hóa. Vui lòng sử dụng mã xác thực hoặc nhà cung cấp xác thực bên ngoài.", "signInWithPasskey": "Đăng nhập bằng mật khẩu", "passkeyLoginFailed": "Đăng nhập bằng mật khẩu thất bại", - "attemptsRemaining": "{{count}} lần thử còn lại" + "attemptsRemaining": "{{count}} lần thử còn lại", + "confirmResetDataWipe": "Tài khoản này chưa đăng nhập kể từ khi nâng cấp mã hóa, vì vậy dữ liệu đã lưu trữ không thể khôi phục nếu không có mật khẩu cũ. Việc đặt lại sẽ xóa vĩnh viễn các máy chủ, thông tin đăng nhập và đoạn mã của tài khoản này. Bạn có muốn tiếp tục không?" }, "hostKey": { "verifyNewHost": "Xác minh khóa máy chủ SSH", @@ -2323,6 +2371,13 @@ "serviceLinks": "Liên kết dịch vụ", "homepagePreview": "Xem trước trang chủ" }, + "donation": { + "title": "Bạn thấy Termix thế nào?", + "body": "Termix là phần mềm mã nguồn mở miễn phí, được xây dựng và duy trì bởi một nhóm hai người trong thời gian rảnh rỗi. Nếu nó thay thế công cụ thương mại mà bạn lẽ ra phải trả tiền, khoản quyên góp của bạn sẽ giúp trang trải chi phí lưu trữ và duy trì quá trình phát triển. Hiện tại, chúng tôi chỉ chấp nhận quyên góp bằng tiền điện tử.", + "milestones": "Các khoản quyên góp cũng giúp tài trợ cho thời gian nghiên cứu và học hỏi những gì cần thiết để xây dựng các tính năng như hỗ trợ SAML và Kubernetes. Xem tiến độ trên trang quyên góp.", + "cta": "Quyên tặng", + "dismiss": "Có lẽ sau này" + }, "dashboardTab": { "stable": "ỔN ĐỊNH", "hostsOnline": "Hosts Online", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "Không thể cập nhật trạng thái quản trị viên.", "allSessionsRevoked": "Tất cả các phiên đã bị hủy bỏ", "revokeSessionsFailed": "Không thể hủy bỏ các phiên", + "manageUserData": "Quản lý dữ liệu người dùng", + "backToUsers": "Trở lại trang người dùng", + "manageTabAccount": "Tài khoản", + "manageTabHosts": "Người dẫn chương trình", + "manageTabCredentials": "Thông tin xác thực", + "manageTabSnippets": "Những đoạn trích", + "manageTabSessions": "Phiên họp", + "manageTabDanger": "Sự nguy hiểm", + "manageEditorBack": "Quay lại dữ liệu của {{username}}", + "dataLockedBadge": "ĐÃ KHÓA", + "dataLockedNotice": "Dữ liệu của {{username}}sẽ bị khóa cho đến khi họ đăng nhập lần tiếp theo. Máy chủ, thông tin đăng nhập và đoạn mã của họ không thể được xem hoặc chỉnh sửa cho đến lúc đó.", + "resetPasswordTitle": "Đặt lại mật khẩu", + "resetPasswordOidcOnly": "Người dùng này đăng nhập thông qua nhà cung cấp bên ngoài và không có mật khẩu.", + "resetPasswordPlaceholder": "Mật khẩu mới", + "resetPasswordBtn": "Cài lại", + "resetPasswordWorking": "Đang thiết lập lại...", + "resetPasswordSuccess": "Đặt lại mật khẩu", + "resetPasswordSuccessWiped": "Đã đặt lại mật khẩu. Dữ liệu được mã hóa của người dùng đã bị xóa.", + "resetPasswordFailed": "Không thể đặt lại mật khẩu.", + "resetPasswordConfirmWipe": "{{username}} chưa đăng nhập kể từ khi nâng cấp mã hóa, vì vậy dữ liệu của họ không thể khôi phục. Việc đặt lại ngay bây giờ sẽ xóa máy chủ, thông tin đăng nhập và đoạn mã của họ. Tiếp tục?", + "totpSectionTitle": "Xác thực hai yếu tố", + "totpStatusEnabled": "TOTP đã được bật cho người dùng này.", + "totpStatusDisabled": "TOTP chưa được bật cho người dùng này.", + "disableTotp": "Vô hiệu hóa", + "disableTotpConfirm": "Vô hiệu hóa xác thực hai yếu tố cho {{username}}? Họ sẽ có thể đăng nhập chỉ bằng mật khẩu của mình.", + "totpDisabledSuccess": "TOTP bị vô hiệu hóa", + "totpDisableFailed": "Không thể vô hiệu hóa TOTP.", + "manageApiKeys": "Khóa API", + "apiKeyNamePlaceholder": "Tên khóa", + "apiKeyCopyNotice": "Hãy sao chép khóa này ngay bây giờ, nó sẽ không hiển thị lại nữa.", + "noApiKeysForUser": "Không có khóa API", + "apiKeyDeleteFailed": "Không thể xóa khóa API.", + "exportUserData": "Xuất dữ liệu", + "exportUserDataDesc": "Tải xuống dữ liệu về máy chủ, thông tin đăng nhập và trình quản lý tập tin của người dùng này dưới dạng JSON. Thông tin bí mật đã được giải mã.", + "exportUserDataSuccess": "Dữ liệu người dùng đã xuất", + "exportUserDataFailed": "Không thể xuất dữ liệu người dùng.", + "hostsCount": "{{count}} máy chủ", + "addHostForUser": "Thêm máy chủ", + "noHostsForUser": "Người dùng này không có máy chủ", + "connectToHost": "Kết nối", + "deleteHostConfirm": "Xóa máy chủ \"{{name}}\" thuộc về {{username}}?", + "hostDeletedSuccess": "Máy chủ đã bị xóa", + "hostDeleteFailed": "Không thể xóa máy chủ", + "credentialsCount": "{{count}} thông tin xác thực", + "addCredentialForUser": "Thêm thông tin đăng nhập", + "noCredentialsForUser": "Người dùng này không có thông tin đăng nhập.", + "deleteCredentialConfirm": "Xóa thông tin xác thực \"{{name}}\" thuộc về {{username}}?", + "credentialDeletedSuccess": "Thông tin đăng nhập đã bị xóa", + "credentialDeleteFailed": "Không thể xóa thông tin đăng nhập", + "snippetsCount": "{{count}} đoạn trích", + "addSnippetForUser": "Thêm đoạn mã", + "noSnippetsForUser": "Người dùng này không có đoạn trích nào.", + "snippetRequiredFields": "Tên và nội dung đoạn mã là bắt buộc", + "snippetNamePlaceholder": "Tên đoạn trích", + "snippetContentPlaceholder": "Nội dung lệnh", + "snippetFolderPlaceholder": "Thư mục (tùy chọn)", + "snippetSaved": "Đoạn mã đã được lưu", + "snippetSaveFailed": "Không thể lưu đoạn mã", + "deleteSnippetConfirm": "Xóa đoạn mã \"{{name}}\" thuộc về {{username}}?", + "snippetDeletedSuccess": "Đoạn văn đã bị xóa", + "snippetDeleteFailed": "Không thể xóa đoạn mã", + "noSessionsForUser": "Không có phiên hoạt động nào", + "deleteUserDangerDesc": "Xóa vĩnh viễn {{username}} và tất cả dữ liệu của chúng (máy chủ, thông tin đăng nhập, đoạn trích, lịch sử). Thao tác này không thể hoàn tác.", + "deleteUserConfirm": "Xóa vĩnh viễn {{username}} và tất cả dữ liệu của chúng?", + "deleteUserAdminBlocked": "Hãy gỡ bỏ quyền quản trị trước khi xóa người dùng này.", "createRoleRequired": "Tên và tên hiển thị là bắt buộc", "createRoleSuccess": "Vai trò \"{{name}}\" đã được tạo", "createRoleFailed": "Không thể tạo vai trò", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "Lịch sử lệnh", "hostDefaultsCommandHistoryDesc": "Theo dõi lịch sử lệnh trên các máy chủ mới theo mặc định", "hostDefaultsSaved": "Cài đặt mặc định của máy chủ đã được lưu", - "hostDefaultsSaveFailed": "Không thể lưu cài đặt mặc định của máy chủ." + "hostDefaultsSaveFailed": "Không thể lưu cài đặt mặc định của máy chủ.", + "rolePermissions": { + "count": "{{count}} quyền hạn", + "editAction": "Chỉnh sửa quyền", + "loadError": "Không thể tải danh mục quyền.", + "saved": "Quyền hạn vai trò đã được lưu", + "saveError": "Không thể lưu quyền quản trị", + "save": "Cứu", + "saving": "Đang lưu..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "Thất bại" }, "userProfile": { + "donateTitle": "Hãy giữ cho Termix luôn sống sót", + "donateDescription": "Termix là phần mềm miễn phí, tự lưu trữ và được xây dựng bởi một vài người trong thời gian rảnh rỗi. Nếu nó giúp bạn tiết kiệm thời gian hoặc tiền bạc, một khoản quyên góp bằng tiền điện tử sẽ giúp duy trì hoạt động của nó.", + "donateMilestones": "Các khoản quyên góp giúp tài trợ cho thời gian nghiên cứu và học hỏi những gì cần thiết để xây dựng hỗ trợ SAML, Kubernetes và Agent. Hãy theo dõi tiến độ và quyên góp.", + "donateButton": "Quyên góp tiền điện tử", "storageModeLocal": "Trình duyệt", "storageModeCloud": "Cơ sở dữ liệu", "storageModeDescription": "Trình duyệt chỉ lưu trữ cài đặt trong trình duyệt này. Cơ sở dữ liệu được đồng bộ hóa với máy chủ và tải trên mọi thiết bị.", @@ -3011,6 +3144,10 @@ "twoFaOn": "TRÊN", "twoFaOff": "Tắt", "versionLabel": "Phiên bản", + "betaProgramTitle": "Chương trình Beta", + "betaProgramDescription": "Hãy thử nghiệm các tính năng sắp ra mắt sớm với thẻ Docker :beta được cập nhật hàng tuần. Phiên bản này chưa ổn định, không dành cho môi trường sản xuất.", + "betaProgramFeedback": "Phát hiện lỗi? Báo cáo tại đây.", + "betaProgramLearnMore": "Tìm hiểu thêm", "deleteAccount": "Xóa tài khoản", "deleteAccountDescription": "Xóa vĩnh viễn tài khoản của bạn", "changeServerDescription": "Chuyển sang máy chủ phụ trợ Termix khác", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 66f76ff7..f56e1a15 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -46,7 +46,7 @@ "fromVault": "来自凭证库", "linkedToTermixId": "通过 Termix ID 发布", "selectCredential": "选择凭证...", - "import": "进口", + "import": "导入", "caTitle": "证书颁发机构", "caIntro": "在服务器上信任此 CA,它将接受您签署的任何证书。轮换证书可以一次性撤销所有证书;证书也会自动过期。", "caEnable": "启用 CA", @@ -72,7 +72,7 @@ "folders": "文件夹", "folder": "文件夹", "password": "密码", - "key": "钥匙", + "key": "密钥", "sshPrivateKey": "SSH 私钥", "upload": "上传", "keyPassword": "密钥密码", @@ -126,7 +126,7 @@ "openFullView": "打开完整视图", "previewTitle": "首页预览", "cancel": "取消", - "save": "节省", + "save": "保存", "title_label": "标题", "widgetTitlePlaceholder": "小部件标题(可选)", "url": "URL", @@ -143,13 +143,13 @@ "format24h": "24小时", "content": "内容", "backgroundColor": "背景颜色", - "host": "主持人", + "host": "主机", "showMetrics": "显示指标", "links": "链接", "addLink": "添加链接", "linkLabel": "标签", "linkUrl": "URL", - "removeLink": "消除", + "removeLink": "移除", "categoryLinks": "链接", "categoryInfo": "信息", "categorySystem": "系统", @@ -215,14 +215,14 @@ "overviewDatabase": "数据库", "overviewUptime": "正常运行时间", "noHosts": "未配置任何主机", - "hostGridHosts": "主持人", + "hostGridHosts": "主机", "hostGridAllHint": "留空则显示所有主机。", "columns": "列", "showIp": "显示 IP 地址", "connectionType": "连接类型", "layout": "布局", "showStatus": "显示状态", - "showHostName": "节目主持人姓名", + "showHostName": "显示主机名称", "noDockerActivity": "无 Docker 活动", "noActivity": "无活动", "showAcknowledged": "显示已确认", @@ -269,25 +269,25 @@ "widgetSearchLinksDesc": "带有内联输入的快速搜索快捷按钮", "widgetLinkTreeName": "链接树", "widgetLinkTreeDesc": "带有标题的链接分组", - "calMon": "莫", - "calTue": "图", - "calWed": "我们", - "calThu": "钍", - "calFri": "弗朗西斯", - "calSat": "萨", - "calSun": "苏", + "calMon": "星期一", + "calTue": "星期二", + "calWed": "星期三", + "calThu": "星期四", + "calFri": "星期五", + "calSat": "星期六", + "calSun": "星期日", "startOnMonday": "周一开始新的一周", "countdownNoDate": "未设定目标日期", "countdownPast": "事件已结束", "countdownDays": "天", "countdownHours": "小时", - "countdownMinutes": "最小", + "countdownMinutes": "分", "countdownSeconds": "秒", "countdownLabel": "标签", "countdownLabelPlaceholder": "例如:发布日", - "countdownShowDays": "演出日", - "countdownShowHours": "演出时间", - "targetDate": "目标日期", + "countdownShowDays": "显示天", + "countdownShowHours": "显示小时", + "targetDate": "显示日期", "searchEngine": "搜索引擎", "customSearchUrl": "自定义搜索网址", "searchPlaceholder": "搜索...", @@ -312,7 +312,7 @@ "displayField": "显示字段", "jsonPath": "JSON 路径", "customApiLabel": "标签", - "customApiLabelPlaceholder": "例如温度", + "customApiLabelPlaceholder": "例如:温度", "customApiUnit": "单元", "customApiNoUrl": "未配置 API URL", "customApiError": "获取失败", @@ -327,7 +327,7 @@ "compactMode": "紧凑模式", "showDetailed": "显示详细信息", "selectHosts": "选择主机", - "allHosts": "所有主持人", + "allHosts": "所有主机", "listLayout": "列表", "gridLayout": "网格", "terminal": "终端", @@ -337,8 +337,8 @@ "range1h": "1小时", "range6h": "6小时", "range24h": "24小时", - "metricNetRx": "净下载", - "metricNetTx": "净上传", + "metricNetRx": "网络下载", + "metricNetTx": "网络上传", "severityFilter": "严重性过滤器", "filterAll": "全部", "accentColor": "强调色", @@ -362,7 +362,7 @@ "widgetFileManagerDesc": "为已配置主机提供的嵌入式 SFTP 文件管理器", "widgetDockerName": "Docker 管理器", "widgetDockerDesc": "为已配置主机提供的嵌入式 Docker 容器管理器", - "widgetTunnelName": "隧道经理", + "widgetTunnelName": "隧道管理器", "widgetTunnelDesc": "为已配置主机提供的嵌入式 SSH 隧道管理器", "widgetNoHostSelected": "未配置主机" }, @@ -387,7 +387,7 @@ "localServer": "本地服务器", "savedServers": "已保存的服务器", "noSavedServers": "没有已保存的服务器", - "removeServer": "消除" + "removeServer": "清除" }, "versionCheck": { "error": "版本检查错误", @@ -408,7 +408,7 @@ "common": { "close": "关闭", "minimize": "最小化", - "online": "在线的", + "online": "在线", "offline": "离线", "unknown": "未知", "continue": "继续", @@ -424,7 +424,7 @@ "copied": "已复制", "connecting": "正在连接...", "updateAvailable": "更新可用", - "appName": "特米克斯", + "appName": "Termix", "openInNewTab": "在新标签页中打开", "noReleases": "无发布", "updatesAndReleases": "更新与发布", @@ -436,21 +436,21 @@ "username": "用户名", "login": "登录", "logout": "注销", - "register": "登记", + "register": "注册", "password": "密码", "confirmPassword": "确认密码", - "back": "后退", - "save": "节省", + "back": "返回", + "save": "保存", "saving": "保存...", "delete": "删除", "rename": "重命名", "edit": "编辑", "add": "添加", "confirm": "确认", - "no": "不", - "or": "或者", - "next": "下一个", - "previous": "以前的", + "no": "否", + "or": "或", + "next": "下一步", + "previous": "上一步", "refresh": "刷新", "language": "语言", "checking": "检查...", @@ -474,7 +474,7 @@ "required": "必需的" }, "nav": { - "home": "家", + "home": "主页", "terminal": "终端", "docker": "Docker", "tunnels": "隧道", @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "逗号分隔的名称模式使 Docker 能够匹配虚拟机", "proxmoxPreferredRanges": "首选 IP 地址范围", "proxmoxPreferredRangesDesc": "当访客拥有多个接口时,用于选择 IP 地址的优先级前缀以逗号分隔", + "proxmoxAutoSync": "自动同步访客", + "proxmoxAutoSyncDesc": "定期发现此 Proxmox 节点,并在会话解锁期间创建或更新导入的客户主机。", + "proxmoxSyncInterval": "同步间隔(分钟)", + "proxmoxSyncIntervalDesc": "最短 5 分钟。调度程序会跳过已锁定的用户会话。", + "proxmoxMarkMissing": "马克缺席的客人", + "proxmoxMarkMissingDesc": "如果之前导入的虚拟机消失而不是被删除,则添加 proxmox-missing 标签。", + "proxmoxLastSync": "上次同步", + "proxmoxLastSyncNever": "尚未同步", + "proxmoxLastSyncNoResult": "无结果详情", + "proxmoxLastSyncSummary": "{{created}} 已创建, {{updated}} 已更新, {{markedMissing}} 缺失, {{skipped}} 已跳过", + "proxmoxLastSyncStatus": { + "success": "成功", + "error": "失败的", + "pending": "待办的" + }, "proxmoxDiscoverAction": "发现并导入 Proxmox 访客", "proxmoxImportTitle": "从 Proxmox 导入", "proxmoxSelectHost": "选择 Proxmox 主机…", @@ -1167,7 +1182,57 @@ "shareHost": "共享主机", "shareHostTitle": "分享: {{name}}", "sharing": { - "requiresCredential": "此主机必须使用凭据才能启用共享。请先编辑主机并分配凭据。" + "loadError": "分享数据加载失败,请重试。", + "shareWithSection": "分享", + "usersTab": "用户", + "rolesTab": "角色", + "searchPlaceholder": "搜索用户或角色...", + "noMatches": "未找到匹配项", + "permissionLevelLabel": "权限级别", + "levels": { + "connect": { + "label": "连接", + "description": "仅允许打开会话:终端、远程桌面、文件管理器、隧道和 Docker。无权访问主机配置。" + }, + "view": { + "label": "看法", + "description": "连接后,即可查看主机配置。密钥永远不会显示。" + }, + "edit": { + "label": "编辑", + "description": "查看并修改主机。密钥可以替换但永远无法读取;凭证分配仅限所有者操作。" + }, + "manage": { + "label": "管理", + "description": "编辑、与他人共享主机、更改权限级别和撤销访问权限。" + } + }, + "expiryLabel": "访问权限到期", + "expiry": { + "never": "绝不", + "oneHour": "1小时", + "oneDay": "24小时", + "sevenDays": "7天", + "thirtyDays": "30天", + "custom": "风俗" + }, + "customHoursPlaceholder": "访问权限到期时间(剩余时间)", + "shareButton": "分享", + "shareWithCount": "分享({{count}})", + "currentAccess": "当前访问权限", + "noAccessEntries": "此主机尚未共享", + "grantedBy": "由……授予", + "expires": "过期", + "expired": "已到期", + "never": "绝不", + "revoke": "撤销", + "accessUpdated": "访问已更新", + "accessUpdateFailed": "更新访问权限失败", + "sharedBadge": "共享", + "sharedBadgeTooltip": "由 {{owner}} 共享({{level}} 访问权限)", + "viewOnlyBanner": "此主机由 {{owner}} 与您共享,您拥有查看权限。配置信息为只读。", + "sharedEditBanner": "此主机由 {{owner}} 与您共享,并授予您编辑权限。更改将应用于实际主机;身份验证引用只能由所有者更改。", + "ownerOnlyControl": "只有房东才能更改此设置" }, "guac": { "connection": "联系", @@ -1302,28 +1367,10 @@ "backspaceKey": "退格键", "saveHostFirst": "先保存主机。", "sharingOptionsAfterSave": "保存主机后即可使用分享选项。", - "sharingLoadError": "共享数据加载失败。请检查您的网络连接并重试。", - "shareHostSection": "共享主机", - "shareWithUser": "与用户分享", - "shareWithRole": "分享角色", - "selectUser": "选择用户", - "selectRole": "选择角色", - "selectUserOption": "选择用户...", - "selectRoleOption": "选择一个角色……", "permissionLevel": "权限级别", - "expiresInHours": "(小时)后过期", - "noExpiryPlaceholder": "无需清洗,因为没有有效期。", - "shareBtn": "分享", - "currentAccess": "当前访问权限", "typeHeader": "类型", "targetHeader": "目标", "permissionHeader": "允许", - "grantedByHeader": "授予", - "expiresHeader": "过期", - "noAccessEntries": "暂无访问记录。", - "expiredLabel": "已到期", - "neverLabel": "绝不", - "revokeBtn": "撤销", "cancelBtn": "取消", "savingBtn": "保存...", "updateHostBtn": "更新主机", @@ -1803,7 +1850,7 @@ "parallelSegmentsLabel": "平行换乘车道", "parallelSegmentsOption": "{{count}} 车道", "parallelSegmentsHint": "大文件会被分割成 256 MB 的数据块。多通道使用独立的连接(类似于同时启动多个传输)以提高总吞吐量。", - "progressTotalSpeed": "{{speed}} total ({{lanes}} lanes)", + "progressTotalSpeed": "{{speed}} 总计 ({{lanes}} 条车道)", "progressTransferringItems": "正在传输文件({{current}} 中的 {{total}})…", "progressBytes": "{{transferred}} / {{total}}", "progressItems": "{{current}} / {{total}} 文件", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "密码登录已禁用。请使用密码密钥或外部身份验证提供商。", "signInWithPasskey": "使用密码登录", "passkeyLoginFailed": "密码登录失败", - "attemptsRemaining": "{{count}} 剩余尝试次数" + "attemptsRemaining": "{{count}} 剩余尝试次数", + "confirmResetDataWipe": "此帐户自加密升级后未登录,因此没有旧密码将无法恢复其存储的数据。重置将永久删除其主机名、凭据和代码片段。是否继续?" }, "hostKey": { "verifyNewHost": "验证 SSH 主机密钥", @@ -2323,6 +2371,13 @@ "serviceLinks": "服务链接", "homepagePreview": "首页预览" }, + "donation": { + "title": "喜欢Termix吗?", + "body": "Termix 是一个免费开源的软件,由我们两人团队利用业余时间开发和维护。如果您用它替代了原本需要付费的商业工具,您的捐赠将有助于支付托管费用并维持软件的持续开发。目前,我们仅接受加密货币捐赠。", + "milestones": "捐款还能帮助我们投入时间进行研究和学习,以开发诸如 SAML 和 Kubernetes 支持等功能。请在捐款页面查看项目进展。", + "cta": "捐", + "dismiss": "也许以后" + }, "dashboardTab": { "stable": "稳定的", "hostsOnline": "在线主持人", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "更新管理员状态失败", "allSessionsRevoked": "所有会话均被撤销", "revokeSessionsFailed": "撤销会话失败", + "manageUserData": "管理用户数据", + "backToUsers": "返回用户", + "manageTabAccount": "帐户", + "manageTabHosts": "主持人", + "manageTabCredentials": "证书", + "manageTabSnippets": "片段", + "manageTabSessions": "会议", + "manageTabDanger": "危险", + "manageEditorBack": "返回 {{username}}的数据", + "dataLockedBadge": "已锁定", + "dataLockedNotice": "用户 {{username}}的数据将被锁定,直到他们下次登录。在此之前,他们的主机名、凭据和代码片段将无法查看或编辑。", + "resetPasswordTitle": "重置密码", + "resetPasswordOidcOnly": "该用户通过外部服务提供商登录,没有密码。", + "resetPasswordPlaceholder": "新密码", + "resetPasswordBtn": "重置", + "resetPasswordWorking": "正在重置……", + "resetPasswordSuccess": "密码重置", + "resetPasswordSuccessWiped": "密码已重置。用户的加密数据已被清除。", + "resetPasswordFailed": "重置密码失败", + "resetPasswordConfirmWipe": "自加密升级以来,{{username}} 一直未登录,因此其数据无法恢复。立即重置将删除其主机、凭据和代码片段。是否继续?", + "totpSectionTitle": "双因素身份验证", + "totpStatusEnabled": "此用户已启用 TOTP", + "totpStatusDisabled": "此用户未启用 TOTP。", + "disableTotp": "禁用", + "disableTotpConfirm": "是否禁用 {{username}}的双因素身份验证?他们将能够仅使用密码登录。", + "totpDisabledSuccess": "TOTP 已禁用", + "totpDisableFailed": "禁用 TOTP 失败", + "manageApiKeys": "API密钥", + "apiKeyNamePlaceholder": "键名", + "apiKeyCopyNotice": "立即复制此密钥,它不会再次显示。", + "noApiKeysForUser": "没有 API 密钥", + "apiKeyDeleteFailed": "删除 API 密钥失败", + "exportUserData": "数据导出", + "exportUserDataDesc": "以 JSON 格式下载此用户的 hosts 文件、凭据和文件管理器数据。密钥已解密。", + "exportUserDataSuccess": "用户数据导出", + "exportUserDataFailed": "用户数据导出失败", + "hostsCount": "{{count}} 主机", + "addHostForUser": "添加主机", + "noHostsForUser": "该用户没有主机。", + "connectToHost": "连接", + "deleteHostConfirm": "删除属于 {{username}} 的主机“{{name}}”?", + "hostDeletedSuccess": "主机已删除", + "hostDeleteFailed": "删除主机失败", + "credentialsCount": "{{count}} 凭证", + "addCredentialForUser": "添加凭据", + "noCredentialsForUser": "此用户没有凭据", + "deleteCredentialConfirm": "删除属于 {{username}} 的凭据“{{name}}”?", + "credentialDeletedSuccess": "凭证已删除", + "credentialDeleteFailed": "删除凭据失败", + "snippetsCount": "{{count}} 代码片段", + "addSnippetForUser": "添加代码片段", + "noSnippetsForUser": "该用户没有任何代码片段。", + "snippetRequiredFields": "代码片段名称和内容均为必填项。", + "snippetNamePlaceholder": "代码片段名称", + "snippetContentPlaceholder": "命令内容", + "snippetFolderPlaceholder": "文件夹(可选)", + "snippetSaved": "代码片段已保存", + "snippetSaveFailed": "保存代码片段失败", + "deleteSnippetConfirm": "删除属于 {{username}} 的片段“{{name}}”?", + "snippetDeletedSuccess": "片段已删除", + "snippetDeleteFailed": "删除代码片段失败", + "noSessionsForUser": "没有活动会话", + "deleteUserDangerDesc": "永久删除 {{username}} 及其所有数据(主机、凭据、代码片段、历史记录)。此操作无法撤销。", + "deleteUserConfirm": "永久删除 {{username}} 及其所有数据?", + "deleteUserAdminBlocked": "删除此用户前,请先移除其管理员权限。", "createRoleRequired": "姓名和显示名称是必填项。", "createRoleSuccess": "角色“{{name}}”已创建", "createRoleFailed": "创建角色失败", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "命令历史记录", "hostDefaultsCommandHistoryDesc": "默认情况下跟踪新主机上的命令历史记录", "hostDefaultsSaved": "主机默认设置已保存", - "hostDefaultsSaveFailed": "保存主机默认设置失败" + "hostDefaultsSaveFailed": "保存主机默认设置失败", + "rolePermissions": { + "count": "{{count}} 权限", + "editAction": "编辑权限", + "loadError": "加载权限目录失败", + "saved": "角色权限已保存", + "saveError": "保存角色权限失败", + "save": "节省", + "saving": "保存..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "失败的" }, "userProfile": { + "donateTitle": "让Termix活下去", + "donateDescription": "Termix 是免费的,由几位业余人士自行托管和开发。如果它为您节省了时间或金钱,欢迎您捐赠加密货币以支持其持续运营。", + "donateMilestones": "捐款将用于资助我们研究和学习构建 SAML、Kubernetes 和 Agent 支持所需的知识。查看进展并捐款。", + "donateButton": "捐赠加密货币", "storageModeLocal": "浏览器", "storageModeCloud": "数据库", "storageModeDescription": "浏览器仅将设置存储在此浏览器中。数据库会同步到服务器,并可在任何设备上加载。", @@ -3011,6 +3144,10 @@ "twoFaOn": "在", "twoFaOff": "离开", "versionLabel": "版本", + "betaProgramTitle": "测试计划", + "betaProgramDescription": "使用每周滚动更新的 :beta Docker 标签,即可提前体验即将推出的功能。此版本不稳定,不适用于生产环境。", + "betaProgramFeedback": "发现漏洞?请在此处报告。", + "betaProgramLearnMore": "了解更多", "deleteAccount": "删除帐户", "deleteAccountDescription": "永久删除您的帐户", "changeServerDescription": "切换到其他 Termix 后端服务器", diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index dd43b699..15bd7d8e 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -864,6 +864,21 @@ "proxmoxDockerDetectionDesc": "逗號分隔的名稱模式使 Docker 能夠匹配虛擬機", "proxmoxPreferredRanges": "首選 IP 位址範圍", "proxmoxPreferredRangesDesc": "當訪客擁有多個介面時,用於選擇 IP 位址的優先順序以逗號分隔", + "proxmoxAutoSync": "自動同步訪客", + "proxmoxAutoSyncDesc": "定期發現此 Proxmox 節點,並在會話解鎖期間建立或更新匯入的客戶主機。", + "proxmoxSyncInterval": "同步間隔(分鐘)", + "proxmoxSyncIntervalDesc": "最短 5 分鐘。調度程式會跳過已鎖定的使用者會話。", + "proxmoxMarkMissing": "馬克缺席的客人", + "proxmoxMarkMissingDesc": "如果先前匯入的虛擬機器消失而不是被刪除,則新增 proxmox-missing 標籤。", + "proxmoxLastSync": "上次同步", + "proxmoxLastSyncNever": "尚未同步", + "proxmoxLastSyncNoResult": "無結果詳情", + "proxmoxLastSyncSummary": "{{created}} 已創建, {{updated}} 已更新, {{markedMissing}} 缺失, {{skipped}} 已跳過", + "proxmoxLastSyncStatus": { + "success": "成功", + "error": "失敗的", + "pending": "待辦的" + }, "proxmoxDiscoverAction": "發現並導入 Proxmox 訪客", "proxmoxImportTitle": "從 Proxmox 導入", "proxmoxSelectHost": "選擇 Proxmox 主機…", @@ -1167,7 +1182,57 @@ "shareHost": "共享主機", "shareHostTitle": "分享: {{name}}", "sharing": { - "requiresCredential": "此主機必須使用憑證才能啟用共用。請先編輯主機並指派憑證。" + "loadError": "分享資料載入失敗,請重試。", + "shareWithSection": "分享", + "usersTab": "使用者", + "rolesTab": "角色", + "searchPlaceholder": "搜尋使用者或角色...", + "noMatches": "未找到匹配項", + "permissionLevelLabel": "權限等級", + "levels": { + "connect": { + "label": "連接", + "description": "僅允許開啟會話:終端機、遠端桌面、檔案管理器、隧道和 Docker。無權存取主機配置。" + }, + "view": { + "label": "看法", + "description": "連線後,即可查看主機配置。密鑰永遠不會顯示。" + }, + "edit": { + "label": "編輯", + "description": "查看並修改主機。金鑰可以替換但永遠無法讀取;憑證分配僅限擁有者操作。" + }, + "manage": { + "label": "管理", + "description": "編輯、與他人共享主機、更改權限等級和撤銷存取權限。" + } + }, + "expiryLabel": "存取權限到期", + "expiry": { + "never": "絕不", + "oneHour": "1小時", + "oneDay": "24小時", + "sevenDays": "7天", + "thirtyDays": "30天", + "custom": "風俗" + }, + "customHoursPlaceholder": "存取權限到期時間(剩餘時間)", + "shareButton": "分享", + "shareWithCount": "分享({{count}})", + "currentAccess": "目前存取權限", + "noAccessEntries": "此主機尚未共用", + "grantedBy": "由……授予", + "expires": "過期", + "expired": "已到期", + "never": "絕不", + "revoke": "撤銷", + "accessUpdated": "訪問已更新", + "accessUpdateFailed": "更新存取權限失敗", + "sharedBadge": "共享", + "sharedBadgeTooltip": "由 {{owner}} 共用({{level}} 存取權限)", + "viewOnlyBanner": "此主機由 {{owner}} 與您分享,您擁有檢視權限。配置資訊為唯讀。", + "sharedEditBanner": "此主機由 {{owner}} 與您分享,並授予您編輯權限。更改將應用於實際主機;身份驗證引用只能由所有者更改。", + "ownerOnlyControl": "只有房東才能更改此設置" }, "guac": { "connection": "聯繫", @@ -1302,28 +1367,10 @@ "backspaceKey": "退格鍵", "saveHostFirst": "先保存主機。", "sharingOptionsAfterSave": "儲存主機後即可使用分享選項。", - "sharingLoadError": "共享資料加載失敗。請檢查您的網路連線並重試。", - "shareHostSection": "共享主機", - "shareWithUser": "與用戶分享", - "shareWithRole": "分享角色", - "selectUser": "選擇用戶", - "selectRole": "選擇角色", - "selectUserOption": "選擇用戶...", - "selectRoleOption": "選擇一個角色…", "permissionLevel": "權限等級", - "expiresInHours": "剩餘時間(小時)", - "noExpiryPlaceholder": "無需清洗,因為沒有有效期限。", - "shareBtn": "分享", - "currentAccess": "目前存取權限", "typeHeader": "類型", "targetHeader": "目標", "permissionHeader": "允許", - "grantedByHeader": "授予", - "expiresHeader": "過期", - "noAccessEntries": "暫無訪問記錄。", - "expiredLabel": "已到期", - "neverLabel": "絕不", - "revokeBtn": "撤銷", "cancelBtn": "取消", "savingBtn": "儲存...", "updateHostBtn": "更新主機", @@ -2190,7 +2237,8 @@ "passwordLoginDisabledDesc": "密碼登入已停用。請使用密碼金鑰或外部身份驗證提供者。", "signInWithPasskey": "使用密碼登入", "passkeyLoginFailed": "密碼登入失敗", - "attemptsRemaining": "{{count}} 剩餘嘗試次數" + "attemptsRemaining": "{{count}} 剩餘嘗試次數", + "confirmResetDataWipe": "此帳戶自加密升級後未登錄,因此沒有舊密碼將無法恢復其儲存的資料。重置將永久刪除其主機名稱、憑證和程式碼片段。是否繼續?" }, "hostKey": { "verifyNewHost": "驗證 SSH 主機金鑰", @@ -2323,6 +2371,13 @@ "serviceLinks": "服務連結", "homepagePreview": "首頁預覽" }, + "donation": { + "title": "喜歡Termix嗎?", + "body": "Termix 是一個免費開源的軟體,由我們兩人團隊利用業餘時間開發和維護。如果您用它取代了原本需要付費的商業工具,您的捐贈將有助於支付託管費用並維持軟體的持續開發。目前,我們僅接受加密貨幣捐贈。", + "milestones": "捐款也能幫助我們投入時間進行研究和學習,以發展諸如 SAML 和 Kubernetes 支援等功能。請在捐款頁面查看專案進度。", + "cta": "捐", + "dismiss": "也許以後" + }, "dashboardTab": { "stable": "穩定的", "hostsOnline": "線上主持人", @@ -2790,6 +2845,71 @@ "updateAdminStatusFailed": "更新管理員狀態失敗", "allSessionsRevoked": "所有會話均被撤銷", "revokeSessionsFailed": "撤銷會話失敗", + "manageUserData": "管理用戶數據", + "backToUsers": "返回用戶", + "manageTabAccount": "帳戶", + "manageTabHosts": "主持人", + "manageTabCredentials": "證書", + "manageTabSnippets": "片段", + "manageTabSessions": "會議", + "manageTabDanger": "危險", + "manageEditorBack": "傳回 {{username}}的數據", + "dataLockedBadge": "已鎖定", + "dataLockedNotice": "使用者 {{username}}的資料將被鎖定,直到他們下次登入。在此之前,他們的主機名稱、憑證和程式碼片段將無法查看或編輯。", + "resetPasswordTitle": "重設密碼", + "resetPasswordOidcOnly": "該用戶透過外部服務提供者登錄,沒有密碼。", + "resetPasswordPlaceholder": "新密碼", + "resetPasswordBtn": "重置", + "resetPasswordWorking": "正在重置…", + "resetPasswordSuccess": "密碼重設", + "resetPasswordSuccessWiped": "密碼已重設。用戶的加密資料已清除。", + "resetPasswordFailed": "重置密碼失敗", + "resetPasswordConfirmWipe": "自加密升級以來,{{username}} 一直未登錄,因此其資料無法恢復。立即重置將刪除其主機、憑證和程式碼片段。是否繼續?", + "totpSectionTitle": "雙重身份驗證", + "totpStatusEnabled": "此使用者已啟用 TOTP", + "totpStatusDisabled": "此使用者未啟用 TOTP。", + "disableTotp": "停用", + "disableTotpConfirm": "是否停用 {{username}}的雙重認證?他們將能夠僅使用密碼登入。", + "totpDisabledSuccess": "TOTP 已停用", + "totpDisableFailed": "停用 TOTP 失敗", + "manageApiKeys": "API金鑰", + "apiKeyNamePlaceholder": "鍵名", + "apiKeyCopyNotice": "立即複製此密鑰,它不會再次顯示。", + "noApiKeysForUser": "沒有 API 金鑰", + "apiKeyDeleteFailed": "刪除 API 金鑰失敗", + "exportUserData": "數據導出", + "exportUserDataDesc": "以 JSON 格式下載此使用者的 hosts 檔案、憑證和檔案管理器資料。密鑰已解密。", + "exportUserDataSuccess": "用戶資料匯出", + "exportUserDataFailed": "用戶資料匯出失敗", + "hostsCount": "{{count}} 主機", + "addHostForUser": "新增主機", + "noHostsForUser": "該用戶沒有主機。", + "connectToHost": "連接", + "deleteHostConfirm": "刪除屬於 {{username}} 的主機「{{name}}」?", + "hostDeletedSuccess": "主機已刪除", + "hostDeleteFailed": "刪除主機失敗", + "credentialsCount": "{{count}} 憑證", + "addCredentialForUser": "新增憑證", + "noCredentialsForUser": "此用戶沒有憑證", + "deleteCredentialConfirm": "刪除屬於 {{username}} 的憑證「{{name}}」?", + "credentialDeletedSuccess": "憑證已刪除", + "credentialDeleteFailed": "刪除憑證失敗", + "snippetsCount": "{{count}} 程式碼片段", + "addSnippetForUser": "新增程式碼片段", + "noSnippetsForUser": "該用戶沒有任何程式碼片段。", + "snippetRequiredFields": "程式碼片段名稱和內容均為必填項。", + "snippetNamePlaceholder": "程式碼片段名稱", + "snippetContentPlaceholder": "命令內容", + "snippetFolderPlaceholder": "資料夾(可選)", + "snippetSaved": "程式碼片段已儲存", + "snippetSaveFailed": "儲存程式碼片段失敗", + "deleteSnippetConfirm": "刪除屬於 {{username}} 的片段「{{name}}」?", + "snippetDeletedSuccess": "片段已刪除", + "snippetDeleteFailed": "刪除程式碼片段失敗", + "noSessionsForUser": "沒有活動會話", + "deleteUserDangerDesc": "永久刪除 {{username}} 及其所有資料(主機、憑證、程式碼片段、歷史記錄)。此操作無法撤銷。", + "deleteUserConfirm": "永久刪除 {{username}} 及其所有資料?", + "deleteUserAdminBlocked": "刪除此使用者前,請先移除其管理員權限。", "createRoleRequired": "姓名和顯示名稱是必填項。", "createRoleSuccess": "角色“{{name}}”已創建", "createRoleFailed": "創建角色失敗", @@ -2822,7 +2942,16 @@ "hostDefaultsCommandHistory": "命令歷史記錄", "hostDefaultsCommandHistoryDesc": "預設會追蹤新主機上的命令歷史記錄", "hostDefaultsSaved": "主機預設設定已儲存", - "hostDefaultsSaveFailed": "儲存主機預設設定失敗" + "hostDefaultsSaveFailed": "儲存主機預設設定失敗", + "rolePermissions": { + "count": "{{count}} 權限", + "editAction": "編輯權限", + "loadError": "載入權限目錄失敗", + "saved": "角色權限已儲存", + "saveError": "儲存角色權限失敗", + "save": "節省", + "saving": "儲存..." + } }, "newUi": { "sidebar": { @@ -2991,6 +3120,10 @@ "executionFailed": "失敗的" }, "userProfile": { + "donateTitle": "讓Termix活下去", + "donateDescription": "Termix 是免費的,由幾位業餘人士自行託管和開發。如果它為您節省了時間或金錢,歡迎您捐贈加密貨幣以支持其持續運作。", + "donateMilestones": "捐款將用於資助我們研究和學習建立 SAML、Kubernetes 和 Agent 支援所需的知識。查看進度並捐款。", + "donateButton": "捐贈加密貨幣", "storageModeLocal": "瀏覽器", "storageModeCloud": "資料庫", "storageModeDescription": "瀏覽器僅將設定儲存在此瀏覽器中。資料庫會同步到伺服器,並可在任何裝置上載入。", @@ -3011,6 +3144,10 @@ "twoFaOn": "在", "twoFaOff": "離開", "versionLabel": "版本", + "betaProgramTitle": "測試計劃", + "betaProgramDescription": "使用每週滾動更新的 :beta Docker 標籤,即可提前體驗即將推出的功能。此版本不穩定,不適用於生產環境。", + "betaProgramFeedback": "發現漏洞?請在此處報告。", + "betaProgramLearnMore": "了解更多", "deleteAccount": "刪除帳戶", "deleteAccountDescription": "永久刪除您的帳戶", "changeServerDescription": "切換到其他 Termix 後端伺服器", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 5ed0de20..98bce891 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -15,7 +15,7 @@ export interface Role { displayName: string; description: string | null; isSystem: boolean; - permissions: string | null; + permissions: string[] | string | null; createdAt: string; updatedAt: string; } @@ -40,7 +40,7 @@ export interface AccessRecord { roleDisplayName: string | null; grantedBy: string; grantedByUsername: string; - permissionLevel: "view"; + permissionLevel: "connect" | "view" | "edit" | "manage"; expiresAt: string | null; createdAt: string; } @@ -188,7 +188,6 @@ export interface AuthResponse { userId?: string; is_oidc?: boolean; totp_enabled?: boolean; - data_unlocked?: boolean; requires_totp?: boolean; temp_token?: string; rememberMe?: boolean; @@ -201,8 +200,9 @@ export interface UserInfo { username: string; is_admin: boolean; is_oidc: boolean; - data_unlocked: boolean; password_hash?: string; + data_unlocked?: boolean; + show_donation_modal?: boolean; } interface UserCount { @@ -1517,6 +1517,7 @@ export { bulkImportSSHHosts, importSSHConfigHosts, discoverProxmoxGuests, + syncProxmoxGuests, bulkUpdateSSHHosts, deleteSSHHost, getSSHHostById, @@ -1715,7 +1716,6 @@ export async function loginUser( rememberMe: response.data.rememberMe, is_oidc: response.data.is_oidc, totp_enabled: response.data.totp_enabled, - data_unlocked: response.data.data_unlocked, token: response.data.token, }; } catch (error) { @@ -1779,6 +1779,14 @@ export async function getUserInfo(): Promise { } } +export async function dismissDonationModal(): Promise { + try { + await authApi.post("/users/me/dismiss-donation-modal"); + } catch (error) { + handleApiError(error, "dismiss donation modal"); + } +} + export async function getCurrentToken(): Promise { try { const response = await authApi.get("/users/me/token"); @@ -1887,12 +1895,14 @@ export async function completePasswordReset( username: string, tempToken: string, newPassword: string, + confirmDataWipe = false, ): Promise> { try { const response = await authApi.post("/users/complete-reset", { username, tempToken, newPassword, + confirmDataWipe, }); return response.data; } catch (error) { @@ -1953,10 +1963,33 @@ export { disableOIDCConfig, getCommandHistoryEnabled, updateCommandHistoryEnabled, + adminResetUserPassword, + adminDisableUserTotp, + adminExportUserData, type ApiKey, type CreatedApiKey, } from "@/api/user-management-api"; +// ADMIN USER DATA MANAGEMENT +// ============================================================================ + +export { + adminGetUserHosts, + adminCreateUserHost, + adminUpdateUserHost, + adminDeleteUserHost, + adminGetHostPassword, + adminGetUserCredentials, + adminGetUserCredentialDetails, + adminCreateUserCredential, + adminUpdateUserCredential, + adminDeleteUserCredential, + adminGetUserSnippets, + adminCreateUserSnippet, + adminUpdateUserSnippet, + adminDeleteUserSnippet, +} from "@/api/admin-user-data-api"; + export { setupTOTP, enableTOTP, @@ -2087,13 +2120,21 @@ export { assignRoleToUser, removeRoleFromUser, shareHost, + updateHostAccess, getHostAccess, revokeHostAccess, + getPermissionsCatalog, + getSharedHosts, shareSnippet, getSnippetAccess, revokeSnippetAccess, getSharedSnippets, } from "@/api/rbac-api"; +export type { + SharePermissionLevel, + ShareTarget, + PermissionCatalogEntry, +} from "@/api/rbac-api"; // ============================================================================ // DOCKER MANAGEMENT API diff --git a/src/ui/shell/CommandPalette.tsx b/src/ui/shell/CommandPalette.tsx index 4af2c573..884025d0 100644 --- a/src/ui/shell/CommandPalette.tsx +++ b/src/ui/shell/CommandPalette.tsx @@ -34,6 +34,7 @@ import { } from "lucide-react"; import { getRecentActivity, type RecentActivityItem } from "@/main-axios"; import type { Host, TabType } from "@/types/ui-types"; +import { canEditHost } from "@/sidebar/host-permissions"; interface CommandPaletteProps { isOpen: boolean; @@ -126,14 +127,18 @@ export function CommandPalette({ }, [isOpen]); useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); setIsOpen(false); } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [setIsOpen]); + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [isOpen, setIsOpen]); const filteredHosts = hosts.filter( (h) => @@ -399,6 +404,11 @@ export function CommandPalette({ {host.name} + {host.isShared && ( + + {t("hosts.sharing.sharedBadge")} + + )} {host.online && ( )} @@ -477,25 +487,32 @@ export function CommandPalette({ Telnet )} -
- + {canEditHost(host) && ( + <> +
+ + + )}
))} diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index df1dee24..f9553dc1 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -18,6 +18,7 @@ import { Pencil, Maximize2, Minimize2, + FolderOpen, } from "lucide-react"; import { tabIcon } from "@/shell/tabUtils"; import { isElectron } from "@/lib/electron"; @@ -40,6 +41,7 @@ export function TabBar({ onAddToSplit, onRemoveFromSplit, onRenameTab, + onOpenFileManager, isAppFullscreen, onToggleAppFullscreen, }: { @@ -56,6 +58,7 @@ export function TabBar({ onAddToSplit: (tabId: string) => void; onRemoveFromSplit: (tabId: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void; + onOpenFileManager?: (tabId: string) => void; isAppFullscreen: boolean; onToggleAppFullscreen: () => void; }) { @@ -527,6 +530,20 @@ export function TabBar({ {t("nav.refreshTab")} )} + {ctxTab.type === "terminal" && + ctxTab.host && + onOpenFileManager && ( + + )}
)} - {roles.map((role) => ( -
-
-
- - {role.displayName} - - {role.isSystem ? ( - - {t("admin.systemBadge")} - - ) : ( - - {t("admin.customBadge")} + {roles.map((role) => { + const permissionCount = rolePermissions(role).length; + return ( +
+
+
+
+ + {role.displayName} + + {role.isSystem ? ( + + {t("admin.systemBadge")} + + ) : ( + + {t("admin.customBadge")} + + )} + {permissionCount > 0 && ( + + {t("admin.rolePermissions.count", { + count: permissionCount, + })} + + )} +
+ + {role.name} +
+ {!role.isSystem && ( +
+ + +
)}
- - {role.name} - -
- {!role.isSystem && ( -
- +
+ {entry.permissions.map((permission) => { + const checked = + wildcardOn || editingPermissions.has(permission); + return ( + + ); + })} +
+
); - }} - > - - -
- )} -
- ))} + })} +
+ + +
+
+ )} +
+ ); + })}
); diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 161ec413..4341c19d 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -33,6 +33,7 @@ import { getCommandHistoryEnabled, updateCommandHistoryEnabled, isElectron, + getConfiguredServerUrl, getUserRoles, } from "@/main-axios"; import { @@ -69,7 +70,7 @@ import { type AdminUser, } from "./AdminManagementSections"; import { toast } from "sonner"; -import { getBasePath } from "@/lib/base-path"; +import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { AdminDatabaseSection, AdminGeneralSettingsSection, @@ -86,6 +87,8 @@ import { AdminLinkAccountDialog, AdminUnlinkAccountDialog, } from "./AdminUserDialogs"; +import { AdminUserManagePanel } from "./AdminUserManagePanel"; +import type { Host } from "@/types/ui-types"; type ApiErrorLike = { response?: { @@ -99,11 +102,18 @@ function apiErrorMessage(error: unknown, fallback: string) { return (error as ApiErrorLike).response?.data?.error || fallback; } -export function AdminSettingsPanel() { +export function AdminSettingsPanel({ + onEditingChange, + onOpenHostTab, +}: { + onEditingChange?: (editing: boolean) => void; + onOpenHostTab?: (host: Host) => void; +} = {}) { const { t } = useTranslation(); const [openSection, setOpenSection] = useState( "general", ); + const [manageUser, setManageUser] = useState(null); const [allowRegistration, setAllowRegistration] = useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = useState(true); const [allowPasswordReset, setAllowPasswordReset] = useState(true); @@ -205,6 +215,11 @@ export function AdminSettingsPanel() { loadSSOProviders(); }, []); + useEffect(() => { + onEditingChange?.(manageUser !== null); + return () => onEditingChange?.(false); + }, [manageUser, onEditingChange]); + useEffect(() => { if (editUserOpen && editUserTarget) { setEditUserRoles([]); @@ -226,6 +241,8 @@ export function AdminSettingsPanel() { isAdmin: user.is_admin, isOidc: user.is_oidc, passwordHash: user.password_hash, + dataUnlocked: user.data_unlocked, + totpEnabled: user.totp_enabled, })), ), ) @@ -717,17 +734,11 @@ export function AdminSettingsPanel() { async function handleExportDatabase() { setExportLoading(true); try { - const isDev = - !isElectron() && - (window.location.port === "5173" || - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"); - - const apiUrl = isElectron() - ? `${window.configuredServerUrl}/database/export` - : isDev - ? `http://localhost:30001/database/export` - : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`; + const apiUrl = getDatabaseTransferUrl("export", { + electron: isElectron(), + configuredServerUrl: getConfiguredServerUrl(), + location: window.location, + }); const response = await fetch(apiUrl, { method: "POST", @@ -769,17 +780,11 @@ export function AdminSettingsPanel() { } setImportLoading(true); try { - const isDev = - !isElectron() && - (window.location.port === "5173" || - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"); - - const apiUrl = isElectron() - ? `${window.configuredServerUrl}/database/import` - : isDev - ? `http://localhost:30001/database/import` - : `${window.location.protocol}//${window.location.host}${getBasePath()}/database/import`; + const apiUrl = getDatabaseTransferUrl("import", { + electron: isElectron(), + configuredServerUrl: getConfiguredServerUrl(), + location: window.location, + }); const formData = new FormData(); formData.append("file", importFile); @@ -823,8 +828,31 @@ export function AdminSettingsPanel() { } } + if (manageUser) { + return ( + setManageUser(null)} + onOpenHostTab={onOpenHostTab} + onUserDeleted={() => { + setUsers((prev) => prev.filter((u) => u.id !== manageUser.id)); + setManageUser(null); + }} + onTotpDisabled={() => { + setUsers((prev) => + prev.map((u) => + u.id === manageUser.id ? { ...u, totpEnabled: false } : u, + ), + ); + }} + /> + ); + } + return ( -
+
toggle("general")} @@ -892,6 +920,7 @@ export function AdminSettingsPanel() { setLinkAccountOpen={setLinkAccountOpen} setUnlinkAccountTarget={setUnlinkAccountTarget} setUnlinkAccountOpen={setUnlinkAccountOpen} + onManageUser={setManageUser} /> +
+ +
+
+ {isHost ? ( + { + setEditor(null); + setEditorTab("general"); + }} + onSave={() => { + setEditor(null); + setEditorTab("general"); + reloadHosts(); + }} + protocols={editorProtocols} + onProtocolChange={(p) => + setEditorProtocols((prev) => ({ ...prev, ...p })) + } + onTabChange={setEditorTab} + hosts={hosts} + credentials={credentials} + adminTargetUserId={user.id} + /> + ) : ( + { + setEditor(null); + setEditorTab("general"); + }} + onSave={() => { + setEditor(null); + setEditorTab("general"); + reloadCredentials(); + }} + adminTargetUserId={user.id} + /> + )} +
+
+ ); + } + + return ( +
+ {/* Back bar */} +
+ + ({ ...tab, icon: null }))} + activeTab={activeTab} + onTabChange={(id) => setActiveTab(id as ManageTabId)} + /> +
+ +
+ {activeTab === "account" && ( + <> + {/* Password reset */} +
+ {sectionHeading(t("admin.resetPasswordTitle"))} + {user.isOidc && !user.passwordHash ? ( + + {t("admin.resetPasswordOidcOnly")} + + ) : ( +
+ setResetPassword(e.target.value)} + /> + +
+ )} +
+ + {/* TOTP */} +
+ {sectionHeading(t("admin.totpSectionTitle"))} +
+ + {totpEnabled + ? t("admin.totpStatusEnabled") + : t("admin.totpStatusDisabled")} + + {totpEnabled && ( + + )} +
+
+ + {/* Roles */} +
+ {sectionHeading(t("admin.userRoles"))} + {userRoles.length > 0 && ( +
+ {userRoles.map((ur) => { + const roleInfo = roles.find((r) => r.id === ur.roleId); + const isSystem = roleInfo?.isSystem ?? false; + return ( + + {ur.roleDisplayName} + {!isSystem && ( + + )} + + ); + })} +
+ )} +
+ {roles + .filter( + (r) => + !r.isSystem && + !userRoles.some((ur) => ur.roleId === r.id), + ) + .map((r) => ( + + ))} +
+
+ + {/* API keys */} +
+ {sectionHeading(t("admin.manageApiKeys"))} +
+ setNewKeyName(e.target.value)} + /> + +
+ {createdKeyToken && ( +
+ + {t("admin.apiKeyCopyNotice")} + + + {createdKeyToken} + +
+ )} + {apiKeys.length === 0 ? ( + + {t("admin.noApiKeysForUser")} + + ) : ( + apiKeys.map((key) => ( +
+
+ + {key.name} + + + {key.tokenPrefix}… + +
+ +
+ )) + )} +
+ + {/* Data export */} +
+ {sectionHeading(t("admin.exportUserData"))} +
+ + {t("admin.exportUserDataDesc")} + + +
+ {!dataUnlocked && lockedNotice} +
+ + )} + + {activeTab === "hosts" && + (!dataUnlocked ? ( + lockedNotice + ) : ( +
+
+ + {t("admin.hostsCount", { count: hosts.length })} + +
+ + +
+
+ {!loading && hosts.length === 0 && ( + + {t("admin.noHostsForUser")} + + )} + {hosts.map((host) => ( +
+
+ + {host.name || host.ip} + + + {host.username ? `${host.username}@` : ""} + {host.ip}:{host.port} + +
+
+ {onOpenHostTab && ( + + )} + + +
+
+ ))} +
+ ))} + + {activeTab === "credentials" && + (!dataUnlocked ? ( + lockedNotice + ) : ( +
+
+ + {t("admin.credentialsCount", { count: credentials.length })} + +
+ + +
+
+ {!loading && credentials.length === 0 && ( + + {t("admin.noCredentialsForUser")} + + )} + {credentials.map((cred) => ( +
+
+ +
+ + {cred.name} + + + {cred.username || "-"} · {cred.type} + +
+
+
+ + +
+
+ ))} +
+ ))} + + {activeTab === "snippets" && + (!dataUnlocked ? ( + lockedNotice + ) : ( +
+
+ + {t("admin.snippetsCount", { count: snippets.length })} + +
+ + +
+
+ {editingSnippet && ( +
+ + setSnippetForm((p) => ({ ...p, name: e.target.value })) + } + /> +