Compare commits

..
Author SHA1 Message Date
f90621a54e v26.10.6 (#1739)
* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00
128 changed files with 16962 additions and 267 deletions

No files matched your search

+69
View File
@@ -250,6 +250,27 @@ jobs:
path: termix_windows_arm64_portable.zip path: termix_windows_arm64_portable.zip
retention-days: 30 retention-days: 30
# The same app with every official plugin, for installs without
# internet. Built last so nothing above packs the full set, and with
# no update files: a full install updates through the normal build and
# downloads the plugins it had on.
- name: Build Windows x64 full installer
if: inputs.artifact_destination != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
- name: Upload Windows x64 full installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_full_nsis
path: release/termix_windows_x64_full_nsis.exe
retention-days: 30
build-linux: build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit' if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
@@ -488,6 +509,28 @@ jobs:
path: release/com.karmaa.termix.flatpakref path: release/com.karmaa.termix.flatpakref
retention-days: 30 retention-days: 30
# Every official plugin, for installs without internet. No update files.
- name: Build Linux x64 full packages
if: inputs.artifact_destination != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --linux AppImage deb --x64 --publish never \
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
- name: Upload Linux x64 full packages
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_full
path: |
release/termix_linux_x64_full_appimage.AppImage
release/termix_linux_x64_full_deb.deb
retention-days: 30
build-macos: build-macos:
runs-on: blacksmith-6vcpu-macos-latest runs-on: blacksmith-6vcpu-macos-latest
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit' if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
@@ -763,6 +806,32 @@ jobs:
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
# Every official plugin, for installs without internet. Signed like the
# normal DMG, with no update files.
- name: Build macOS universal full DMG
if: inputs.artifact_destination != 'none'
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --mac dmg --universal --publish never \
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
- name: Upload macOS universal full DMG
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_universal_full_dmg
path: release/termix_macos_universal_full_dmg.dmg
retention-days: 30
- name: Clean up keychains - name: Clean up keychains
if: always() if: always()
run: | run: |
+24
View File
@@ -8,6 +8,30 @@
- Plugins can say how they show in the mobile app - Plugins can say how they show in the mobile app
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update - The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
- Hosts can have a color and an icon, which sync to the desktop and mobile apps - Hosts can have a color and an icon, which sync to the desktop and mobile apps
- A sidebar setting to turn off alternating row colors
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
### Changed
- body-parser is updated to 2.4.0 for a security fix
### Fixed
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes
- A change pushed from one linked desktop or phone now reaches your other linked devices right away, instead of on their next check a few minutes later
- The macOS menu bar icon shows the Termix logo instead of an empty square
- Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab
- A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed
- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again
- On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
## 26.10.5 ## 26.10.5
+35 -37
View File
@@ -17,8 +17,8 @@
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c", "sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -42,8 +42,8 @@
{ {
"id": "classic-layout", "id": "classic-layout",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.0/classic-layout-1.1.0.tmxplug",
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14", "sha256": "06a94fdbc2eda2f7cdeb4a6bbdc6adb289d524abfe836d3a32ac9b38ad5f65b9",
"tier": "minimal", "tier": "minimal",
"defaultLook": true "defaultLook": true
}, },
@@ -72,15 +72,15 @@
{ {
"id": "databases", "id": "databases",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8", "sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
"tier": "full" "tier": "full"
}, },
{ {
"id": "docker", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.2/docker-1.2.2.tmxplug",
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff", "sha256": "2e6ff0a68a70d83267c250547e6a4e4f6282d557b608500309136aca76881abf",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -90,8 +90,8 @@
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.2/file-manager-1.1.2.tmxplug",
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd", "sha256": "6769e58f23722c06b6e4c187c7ef5ba03f28bbdc19e859104b7a5ead8690c154",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -101,8 +101,8 @@
{ {
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59", "sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
"tier": "full" "tier": "full"
}, },
{ {
@@ -115,8 +115,8 @@
{ {
"id": "host-metrics", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.0/host-metrics-1.3.0.tmxplug",
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3", "sha256": "b38352e4adebf4079918cd7e9e9badfb0a4a9811cc53940852ca71aef860dc26",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -140,8 +140,8 @@
{ {
"id": "import-export", "id": "import-export",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58", "sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
"tier": "standard" "tier": "standard"
}, },
{ {
@@ -182,15 +182,15 @@
{ {
"id": "proxmox", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.0/proxmox-1.2.0.tmxplug",
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf", "sha256": "154c9bc1535bb1d05ccad3ab2cb6d848a28ddbfeb6dc589c5683cd7e81ccd3c3",
"tier": "full" "tier": "full"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.0/remote-desktop-1.3.0.tmxplug",
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7", "sha256": "69177d2a642d549fbd4769833437fdb96065251a4965990b881d1dd583b2832a",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -214,8 +214,8 @@
{ {
"id": "secret-sources", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.0/secret-sources-1.1.0.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134", "sha256": "3ba67666a52a38245284bb0294fd7af52a5613921454e66d3b4972727d7ed5c0",
"tier": "full" "tier": "full"
}, },
{ {
@@ -232,8 +232,8 @@
{ {
"id": "service-manager", "id": "service-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf", "sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
"tier": "full" "tier": "full"
}, },
{ {
@@ -260,8 +260,8 @@
{ {
"id": "snippets", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3", "sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -271,8 +271,8 @@
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.2.0/ssh-terminal-1.2.0.tmxplug",
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8", "sha256": "629b522ae6ff77a8e033b89bb2efa675ec60c147c378012548feec1a005f9533",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -288,9 +288,7 @@
"onboarding": { "onboarding": {
"recommended": false, "recommended": false,
"consent": false, "consent": false,
"enabledByEnv": [ "enabledByEnv": ["OIDC_CLIENT_ID"]
"OIDC_CLIENT_ID"
]
} }
}, },
{ {
@@ -303,8 +301,8 @@
{ {
"id": "sync", "id": "sync",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e", "sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
"tier": "standard" "tier": "standard"
}, },
{ {
@@ -353,8 +351,8 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.2/tunnels-1.2.2.tmxplug",
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159", "sha256": "92d89c5bf7fa43fff790cc5240ae75eaf05c3c9089c6a0dfb68bbc37c517bf04",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -392,8 +390,8 @@
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de", "sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -0,0 +1,14 @@
CREATE TABLE `shared_folder_auth_overrides` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`folder` varchar(255) NOT NULL,
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
`credential_id` int NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
);
--> statement-breakpoint
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -540,6 +540,13 @@
"when": 1792945361809, "when": 1792945361809,
"tag": "0076_host_color_icon", "tag": "0076_host_color_icon",
"breakpoints": true "breakpoints": true
},
{
"idx": 77,
"version": "5",
"when": 1793304929772,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,13 @@
CREATE TABLE "shared_folder_auth_overrides" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"folder" varchar(255) NOT NULL,
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
"credential_id" integer NOT NULL,
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -540,6 +540,13 @@
"when": 1792945360507, "when": 1792945360507,
"tag": "0076_host_color_icon", "tag": "0076_host_color_icon",
"breakpoints": true "breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,13 @@
CREATE TABLE `shared_folder_auth_overrides` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`folder` text NOT NULL,
`protocol` text DEFAULT 'ssh' NOT NULL,
`credential_id` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -505,6 +505,13 @@
"when": 1792945434651, "when": 1792945434651,
"tag": "0071_host_color_icon", "tag": "0071_host_color_icon",
"breakpoints": true "breakpoints": true
},
{
"idx": 72,
"version": "6",
"when": 1793304926942,
"tag": "0072_shared_folder_auth_overrides",
"breakpoints": true
} }
] ]
} }
+2 -1
View File
@@ -1246,8 +1246,9 @@ function createTray() {
let trayIcon; let trayIcon;
if (process.platform === "darwin") { if (process.platform === "darwin") {
const iconPath = path.join(publicRoot, "icons", "16x16.png"); const iconPath = path.join(publicRoot, "icons", "16x16.png");
// The logo is full color on a solid tile. As a template image macOS
// keeps only its outline, which showed as an empty square.
trayIcon = nativeImage.createFromPath(iconPath); trayIcon = nativeImage.createFromPath(iconPath);
trayIcon.setTemplateImage(true);
} else if (process.platform === "win32") { } else if (process.platform === "win32") {
trayIcon = path.join(publicRoot, "icon.ico"); trayIcon = path.join(publicRoot, "icon.ico");
} else { } else {
+24
View File
@@ -83,6 +83,30 @@
"default": "8443", "default": "8443",
"description": "Port for HTTPS." "description": "Port for HTTPS."
}, },
{
"name": "TERMIX_NGINX_TEMPLATE",
"group": "https",
"default": "/app/nginx/nginx-https.conf.template",
"description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker."
},
{
"name": "TERMIX_NGINX_CONF",
"group": "https",
"default": "/tmp/nginx/nginx.conf",
"description": "Where Termix writes the rendered nginx config before it reloads nginx."
},
{
"name": "TERMIX_NGINX_PID",
"group": "https",
"default": "/tmp/nginx/nginx.pid",
"description": "The nginx pid file. Termix only reloads nginx when it exists."
},
{
"name": "TERMIX_APP_DIR",
"group": "https",
"default": "/app",
"description": "Where Termix is installed, used in place of /app in the nginx template."
},
{ {
"name": "SSL_CERT_PATH", "name": "SSL_CERT_PATH",
"group": "https", "group": "https",
+8 -8
View File
@@ -19,7 +19,7 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0", "body-parser": "^2.4.0",
"chalk": "^6.0.0", "chalk": "^6.0.0",
"compression": "^1.8.2", "compression": "^1.8.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -7470,18 +7470,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^2.0.0", "content-type": "^2.1.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.1", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.3",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.15.2", "qs": "^6.16.1",
"raw-body": "^3.0.2", "raw-body": "^3.0.2",
"type-is": "^2.1.0" "type-is": "^2.1.0"
}, },
@@ -15058,7 +15058,7 @@
}, },
"packages/plugin-sdk": { "packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.13", "version": "1.0.22",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2", "@deadendjs/swagger-jsdoc": "^8.1.2",
+1 -1
View File
@@ -55,7 +55,7 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0", "body-parser": "^2.4.0",
"chalk": "^6.0.0", "chalk": "^6.0.0",
"compression": "^1.8.2", "compression": "^1.8.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
+7
View File
@@ -9,10 +9,17 @@ Plugin API 1.13.0.
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note - `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists - `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins - `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
- `shell.editHost(hostId)` opens the editor of a saved host
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
### Fixed ### Fixed
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path - `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
## 1.0.21 ## 1.0.21
+18 -1
View File
@@ -346,7 +346,7 @@ router.post("/notes", async (req, res) => {
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
`hosts.edit` checks a core permission. `hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })` - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another. `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes - Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
@@ -495,6 +495,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and `ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
`contributes.syncEntities`. The entity name is part of the protocol and never changes. `contributes.syncEntities`. The entity name is part of the protocol and never changes.
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
```ts
ctx.sync.registerEntity({
type: "sharedIcons",
table: null,
readOnly: true,
order: 70,
load: (userId) => iconsVisibleTo(userId),
write: (userId, row) => storeCopy(userId, row),
erase: (userId, syncId) => dropCopy(userId, syncId),
});
```
## Frontend ## Frontend
```tsx ```tsx
+37 -7
View File
@@ -177,8 +177,24 @@ export interface SyncWriteEvent {
export interface SyncEntityRegistration { export interface SyncEntityRegistration {
/** Stable wire name. Never change it: records on both sides match on it. */ /** Stable wire name. Never change it: records on both sides match on it. */
type: string; type: string;
/** The table, as returned by ctx.db.define. */ /** The table, as returned by ctx.db.define. Null for an entity with `load`. */
table: unknown; table: unknown;
/**
* Server to desktop only: a desktop never pushes these rows. Required with
* `load`.
*/
readOnly?: boolean;
/**
* Builds the rows the user gets instead of reading the rows they own in
* `table`, for data kept for the whole server (a shared library, rows other
* users share with them). Rows are on the wire: plaintext, each with a
* syncId. Needs `readOnly: true`, `write` and `erase`.
*/
load?: (userId: string) => Promise<SyncRow[]>;
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
write?: (userId: string, wire: SyncRow) => Promise<void>;
/** Drops the desktop copy with this syncId, for an entity with `load`. */
erase?: (userId: string, syncId: string) => Promise<void>;
/** Column holding the owning user id. Defaults to "userId". */ /** Column holding the owning user id. Defaults to "userId". */
userColumn?: string; userColumn?: string;
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */ /** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
@@ -470,6 +486,12 @@ export interface PluginSyncHost {
ensureCore: () => Promise<void>; ensureCore: () => Promise<void>;
onChanged: (listener: () => void) => () => void; onChanged: (listener: () => void) => () => void;
onLocalWrite: (listener: () => void) => () => void; onLocalWrite: (listener: () => void) => () => void;
/** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
idsOf?: (
type: string,
userId: string,
syncIds: string[],
) => Promise<Record<string, number>>;
}; };
/** Rows and bookkeeping of one local user's replica. */ /** Rows and bookkeeping of one local user's replica. */
@@ -1813,13 +1835,21 @@ export interface PluginNetwork {
* asked for every connection, so it must be quick. `open` returns a stream * asked for every connection, so it must be quick. `open` returns a stream
* the SSH client runs over, like a relayed socket. * the SSH client runs over, like a relayed socket.
*/ */
/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
export interface PluginSshTransportHost {
id: number;
ip: string;
port: number;
/** Owner of the host row. */
userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean;
}
export interface PluginSshTransportProvider { export interface PluginSshTransportProvider {
claims: (hostId: number) => boolean; claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
open: (host: { open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
id: number;
ip: string;
port: number;
}) => Promise<import("node:stream").Duplex>;
} }
export interface PluginSshTransports { export interface PluginSshTransports {
+7
View File
@@ -206,6 +206,8 @@ export interface ShellApi {
* user reviews and saves it. Absent in some shells. * user reviews and saves it. Absent in some shells.
*/ */
openHostEditor?: (draft?: HostDraft) => void; openHostEditor?: (draft?: HostDraft) => void;
/** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
editHost?: (hostId: number) => void;
/** Opens a rail view in the left sidebar. */ /** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void; openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */ /** Closes a rail view wherever it is shown. */
@@ -425,6 +427,11 @@ export interface RailItemContribution {
* Zero or nothing shows no badge. * Zero or nothing shows no badge.
*/ */
useBadge?: () => number | null | undefined; useBadge?: () => number | null | undefined;
/**
* A hook for a small status dot on the icon: "ok" (green), "warning" or
* "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
*/
useStatus?: () => "ok" | "warning" | "error" | null | undefined;
} }
export interface PanelProps { export interface PanelProps {
+35 -5
View File
@@ -21,6 +21,30 @@ export interface ExecResult {
code: number | null; code: number | null;
} }
/**
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
* which is what a Chinese-locale Windows console sends (its bytes are almost
* never valid UTF-8) and keeps it only when it decodes without replacement
* characters. Anything else falls back to lossy UTF-8, the old behavior.
*/
export function decodeCommandOutput(buf: Buffer): string {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
buf,
);
} catch {
// not valid UTF-8, try the legacy codepage
}
try {
// A Node built with small-ICU has no GBK decoder and throws here.
const gbk = new TextDecoder("gbk").decode(buf);
if (!gbk.includes("�")) return gbk;
} catch {
// expected - fall through to lossy UTF-8
}
return buf.toString("utf8");
}
/** Runs one non-interactive command over an existing ssh2 client. */ /** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand( export function execCommand(
client: Client, client: Client,
@@ -65,8 +89,10 @@ export function execCommand(
} }
stream = _stream; stream = _stream;
let stdout = ""; // Chunks are buffered and decoded once on close: a multi-byte character
let stderr = ""; // can be split across chunks, and charset detection needs it whole.
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let exitCode: number | null = null; let exitCode: number | null = null;
stream stream
@@ -75,11 +101,15 @@ export function execCommand(
settled = true; settled = true;
exitCode = typeof code === "number" ? code : null; exitCode = typeof code === "number" ? code : null;
cleanup(); cleanup();
resolve({ stdout, stderr, code: exitCode }); resolve({
stdout: decodeCommandOutput(Buffer.concat(stdout)),
stderr: decodeCommandOutput(Buffer.concat(stderr)),
code: exitCode,
});
} }
}) })
.on("data", (data: Buffer) => { .on("data", (data: Buffer) => {
stdout += data.toString("utf8"); stdout.push(data);
}) })
.on("error", (streamErr: Error) => { .on("error", (streamErr: Error) => {
if (!settled) { if (!settled) {
@@ -92,7 +122,7 @@ export function execCommand(
if (stream.stderr) { if (stream.stderr) {
stream.stderr stream.stderr
.on("data", (data: Buffer) => { .on("data", (data: Buffer) => {
stderr += data.toString("utf8"); stderr.push(data);
}) })
.on("error", (stderrErr: Error) => { .on("error", (stderrErr: Error) => {
if (!settled) { if (!settled) {
+3 -1
View File
@@ -403,6 +403,7 @@ getDeviceId (value): () => string
getErrorMessage (value): (error: unknown, fallback?: string) => string getErrorMessage (value): (error: unknown, fallback?: string) => string
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }> getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }> getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
getFolderDefaultsId (value): (name: string) => Promise<number> getFolderDefaultsId (value): (name: string) => Promise<number>
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }> getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }> getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
@@ -526,7 +527,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number> resolveRemoteHostId (value): (syncId: string) => Promise<number>
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }> revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void> revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number) => Promise<void> revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }> revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }> revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
rightDockableIds (value): () => string[] rightDockableIds (value): () => string[]
@@ -544,6 +545,7 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void> saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }> setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
setDeveloperMode (value): (enabled: boolean) => Promise<void> setDeveloperMode (value): (enabled: boolean) => Promise<void>
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }> setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]> setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }> setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
+2
View File
@@ -426,6 +426,7 @@ const ALLOWED = [
"getFolderAccess", "getFolderAccess",
"getFolderDefaultsId", "getFolderDefaultsId",
"getHostAccess", "getHostAccess",
"getFolderAuthOverride",
"getHostAuthOverride", "getHostAuthOverride",
"getHostDefaultsLevel", "getHostDefaultsLevel",
"getHostEditorSection", "getHostEditorSection",
@@ -565,6 +566,7 @@ const ALLOWED = [
"saveUserPreferences", "saveUserPreferences",
"setAllPluginChannels", "setAllPluginChannels",
"setDeveloperMode", "setDeveloperMode",
"setFolderAuthOverride",
"setHostAuthOverride", "setHostAuthOverride",
"setNotificationPrivateEndpoints", "setNotificationPrivateEndpoints",
"setPluginOptions", "setPluginOptions",
+31 -5
View File
@@ -404,6 +404,7 @@ function watchBackend() {
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] }, { cwd: root, stdio: ["ignore", "pipe", "inherit"] },
); );
let first = true; let first = true;
let restartQueued = false;
let buffer = ""; let buffer = "";
tsc.stdout.on("data", (chunk) => { tsc.stdout.on("data", (chunk) => {
buffer += chunk.toString(); buffer += chunk.toString();
@@ -423,14 +424,21 @@ function watchBackend() {
? "starting the backend" ? "starting the backend"
: "backend changed, restarting it"; : "backend changed, restarting it";
first = false; first = false;
void queue(() => restartBackend(reason)); if (restartQueued) continue;
restartQueued = true;
void queue(() => {
restartQueued = false;
return restartBackend(reason);
});
} }
}); });
} }
function watchSdk() { function watchSdk() {
let buildStarted = Date.now();
const changed = createDebouncer(300, () => const changed = createDebouncer(300, () =>
queue(async () => { queue(async () => {
buildStarted = Date.now();
try { try {
await run("npm", ["run", "build:sdk"]); await run("npm", ["run", "build:sdk"]);
} catch (error) { } catch (error) {
@@ -440,7 +448,16 @@ function watchSdk() {
await restartBackend("SDK changed, restarting the backend"); await restartBackend("SDK changed, restarting the backend");
}), }),
); );
watch(SDK_SRC, true, () => changed("sdk")); // Windows also reports reads (access time), so only a file saved after the last build counts
watch(SDK_SRC, true, (_event, filename) => {
try {
const stat = fs.statSync(path.join(SDK_SRC, String(filename ?? "")));
if (stat.isDirectory() || stat.mtimeMs < buildStarted) return;
} catch {
// deleted or renamed, still a change
}
changed("sdk");
});
} }
function watchElectron() { function watchElectron() {
@@ -580,9 +597,18 @@ function watchPlugins(dir) {
queue(() => rebuildPlugin(byId.get(id))), queue(() => rebuildPlugin(byId.get(id))),
); );
for (const plugin of plugins) { for (const plugin of plugins) {
watch(plugin.repo, true, (_event, filename) => { // Only source folders, so node_modules and .git are never watched
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id); const onEvent = (prefix) => (_event, filename) => {
}); const name = prefix
? path.join(prefix, String(filename ?? ""))
: filename;
if (isPluginEdit(plugin.repo, name)) changed(plugin.id);
};
watch(plugin.repo, false, onEvent(""));
for (const sub of ["src", "locales", "migrations"]) {
const target = path.join(plugin.repo, sub);
if (fs.existsSync(target)) watch(target, true, onEvent(sub));
}
} }
log(`watching ${plugins.length} plugin(s) in ${dir}`); log(`watching ${plugins.length} plugin(s) in ${dir}`);
} }
+13
View File
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
); );
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder); CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
folder TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'ssh',
credential_id INTEGER NOT NULL,
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 CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
CREATE TABLE IF NOT EXISTS plugins ( CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
+32
View File
@@ -490,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
], ],
); );
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = mysqlTable(
"shared_folder_auth_overrides",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = mysqlTable( export const sharedHostSecrets = mysqlTable(
"shared_host_secrets", "shared_host_secrets",
{ {
+32
View File
@@ -490,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
], ],
); );
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = pgTable(
"shared_folder_auth_overrides",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = pgTable( export const sharedHostSecrets = pgTable(
"shared_host_secrets", "shared_host_secrets",
{ {
+32
View File
@@ -480,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
], ],
); );
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = sqliteTable(
"shared_folder_auth_overrides",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: text("folder").notNull(),
protocol: text("protocol").notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = sqliteTable( export const sharedHostSecrets = sqliteTable(
"shared_host_secrets", "shared_host_secrets",
{ {
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js"; import { SettingsRepository } from "./settings-repository.js";
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js"; import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js"; import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js"; import { TrustedDeviceRepository } from "./trusted-device-repository.js";
@@ -316,6 +317,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
); );
} }
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
return new SharedFolderAuthOverrideRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_folder_auth_override_repository_write",
),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository { export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository( return new SshCredentialUsageRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -0,0 +1,115 @@
import { and, eq, inArray } from "drizzle-orm";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { sharedFolderAuthOverrides } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { deleteReturning, upsert } from "./returning.js";
/** "A / B / C" and the folders above it, closest first. */
export function folderAndParents(folder: string | null | undefined): string[] {
const parts = (folder ?? "")
.split(" / ")
.map((part) => part.trim())
.filter(Boolean);
const out: string[] = [];
for (let i = parts.length; i > 0; i--) {
out.push(parts.slice(0, i).join(" / "));
}
return out;
}
export class SharedFolderAuthOverrideRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findCredentialId(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<number | null> {
const rows = await this.context.drizzle
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
)
.limit(1);
return rows[0]?.credentialId ?? null;
}
/**
* The login set on the host's folder or the closest folder above it, for
* one protocol. Null when none is set.
*/
async findClosest(
userId: string,
hostFolder: string | null | undefined,
protocol: AuthOverrideProtocol,
): Promise<{ folder: string; credentialId: number } | null> {
const folders = folderAndParents(hostFolder);
if (folders.length === 0) return null;
const rows = await this.context.drizzle
.select({
folder: sharedFolderAuthOverrides.folder,
credentialId: sharedFolderAuthOverrides.credentialId,
})
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.protocol, protocol),
inArray(sharedFolderAuthOverrides.folder, folders),
),
);
for (const folder of folders) {
const row = rows.find((candidate) => candidate.folder === folder);
if (row) return row;
}
return null;
}
async setCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number,
): Promise<void> {
await upsert(
this.context,
sharedFolderAuthOverrides,
{ userId, folder, protocol, credentialId },
{
target: [
sharedFolderAuthOverrides.userId,
sharedFolderAuthOverrides.folder,
sharedFolderAuthOverrides.protocol,
],
set: { credentialId, updatedAt: new Date().toISOString() },
},
);
await this.onWrite?.();
}
async clearCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<boolean> {
const rows = await deleteReturning(
this.context,
sharedFolderAuthOverrides,
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
);
if (rows.length > 0) await this.onWrite?.();
return rows.length > 0;
}
}
+11 -1
View File
@@ -2706,6 +2706,10 @@ router.put(
* summary: Upload a .tmxplug to install from a file * summary: Upload a .tmxplug to install from a file
* description: > * description: >
* Developer mode only. The body is the raw .tmxplug (at most 50 MB). * Developer mode only. The body is the raw .tmxplug (at most 50 MB).
The release's .sig may come base64 encoded in the
X-Termix-Plugin-Signature header; an official one makes the file
install as an official plugin, and it may then fill in a plugin
whose download failed.
* The file is checked (archive, manifest, plugin API) and held for 30 * The file is checked (archive, manifest, plugin API) and held for 30
* minutes, but nothing is loaded. The answer lists what it asks for so * minutes, but nothing is loaded. The answer lists what it asks for so
* the admin can agree, then POST /plugins/upload/{token}/install * the admin can agree, then POST /plugins/upload/{token}/install
@@ -2751,7 +2755,13 @@ router.post(
} }
try { try {
const { stageUpload } = await import("../../plugins/manage.js"); const { stageUpload } = await import("../../plugins/manage.js");
res.json(await stageUpload(req.body)); // The release's .sig, base64 encoded, so an official file installs as
// official (and may write the tables its 2.8 version left).
const header = req.get("x-termix-plugin-signature");
const signature = header
? Buffer.from(header, "base64").toString("utf8")
: null;
res.json(await stageUpload(req.body, signature));
} catch (error) { } catch (error) {
sendManageError( sendManageError(
res, res,
+209 -3
View File
@@ -11,6 +11,7 @@ import {
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js"; import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import { import {
assertHostsUnlocked, assertHostsUnlocked,
hostLock,
hasHostLocks, hasHostLocks,
requireUnlockedHost, requireUnlockedHost,
sendHostLocked, sendHostLocked,
@@ -36,8 +37,10 @@ import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentUserRepository, createCurrentUserRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
const router = express.Router(); const router = express.Router();
@@ -722,14 +725,40 @@ router.get(
}, },
); );
/** The host grants that went to the same user or role as a folder rule. */
export function grantsForFolderRule<
T extends { userId?: string | null; roleId?: number | null },
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
return entries.filter((entry) =>
rule.userId
? entry.userId === rule.userId
: rule.roleId != null && entry.roleId === rule.roleId,
);
}
/** /**
* @openapi * @openapi
* /rbac/folder/access/{id}: * /rbac/folder/access/{id}:
* delete: * delete:
* summary: Stop a folder share from applying to hosts added later * summary: Stop a folder share from applying to hosts added later
* description: Grants already fanned out to hosts stay; revoke those per host. * description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on.
* tags: * tags:
* - RBAC * - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: fromHosts
* schema:
* type: boolean
* responses:
* 200:
* description: Removed. removedFromHosts counts the host grants removed.
* 404:
* description: No such rule.
*/ */
router.delete( router.delete(
"/folder/access/:id", "/folder/access/:id",
@@ -741,11 +770,35 @@ router.delete(
} }
try { try {
const repository = createCurrentFolderAccessRepository(); const repository = createCurrentFolderAccessRepository();
if (!(await repository.findById(ruleId, req.userId!))) { const rule = await repository.findById(ruleId, req.userId!);
if (!rule) {
return res.status(404).json({ error: "Rule not found" }); return res.status(404).json({ error: "Rule not found" });
} }
await repository.deleteById(ruleId, req.userId!); await repository.deleteById(ruleId, req.userId!);
res.json({ success: true });
let removedFromHosts = 0;
if (req.query.fromHosts === "true") {
const hosts =
await createCurrentHostFolderRepository().listHostsInFolder(
req.userId!,
rule.folder,
);
const access = createCurrentRbacAccessRepository();
for (const host of hosts) {
if (hostLock(host.id)) continue;
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
continue;
let changed = false;
const entries = await access.listHostAccess(host.id);
for (const entry of grantsForFolderRule(entries, rule)) {
await access.revokeHostAccess(entry.id, host.id);
removedFromHosts++;
changed = true;
}
if (changed) await pruneHostGrants(host.id);
}
}
res.json({ success: true, removedFromHosts });
} catch (error) { } catch (error) {
databaseLogger.error("Failed to delete folder access rule", error, { databaseLogger.error("Failed to delete folder access rule", error, {
operation: "delete_folder_access", operation: "delete_folder_access",
@@ -2174,4 +2227,157 @@ router.get(
}, },
); );
/** A folder path as hosts store it ("Parent / Child"), or null. */
function folderParam(value: unknown): string | null {
if (typeof value !== "string") return null;
const folder = value
.split(" / ")
.map((part) => part.trim())
.filter(Boolean)
.join(" / ");
return folder && folder.length <= 255 ? folder : null;
}
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* get:
* summary: Get the current user's personal login for shared hosts in a folder
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* - in: query
* name: folder
* required: true
* schema: { type: string }
* responses:
* 200:
* description: The folder, protocol and credential id, or null when none is set.
* 400:
* description: Invalid folder or protocol.
*/
router.get(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.query.folder);
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
const credentialId =
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
userId,
folder,
protocol,
);
res.json({ folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to get folder login", error);
res.status(500).json({ error: "Failed to fetch credential" });
}
},
);
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* put:
* summary: Set the current user's personal login for shared hosts in a folder
* description: >
* Used for every host shared with the user in this folder and its
* subfolders that has no personal login of its own. credentialId null
* clears it.
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [folder, credentialId]
* properties:
* folder: { type: string }
* credentialId: { type: integer, nullable: true }
* responses:
* 200:
* description: Saved.
* 400:
* description: Invalid folder, protocol or credential id.
* 404:
* description: The credential is not one the user can use.
*/
router.put(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.body?.folder);
const credentialId = req.body?.credentialId ?? null;
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
if (
credentialId !== null &&
(!Number.isInteger(credentialId) || credentialId <= 0)
) {
return res.status(400).json({
error: "credentialId must be a positive integer or null",
});
}
const repository = createCurrentSharedFolderAuthOverrideRepository();
if (credentialId === null) {
await repository.clearCredential(userId, folder, protocol);
} else {
if (!(await findUsableCredential(credentialId, userId))) {
return res.status(404).json({ error: "Credential not found" });
}
await repository.setCredential(userId, folder, protocol, credentialId);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action:
credentialId === null
? "clear_shared_folder_auth_override"
: "set_shared_folder_auth_override",
resourceType: "folder",
resourceName: folder,
details: JSON.stringify({ protocol, credentialId }),
ipAddress,
userAgent,
success: true,
}).catch(() => undefined);
res.json({ success: true, folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to set folder login", error);
res.status(500).json({ error: "Failed to update credential" });
}
},
);
export default router; export default router;
@@ -0,0 +1,95 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserDeleteAccountRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account and all of their data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body ?? {};
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
// Same cleanup as an admin delete, so plugins hear user.deleted too.
await deleteUserAndRelatedData(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
}
+3 -81
View File
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js"; import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js"; import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
import { registerAuthCompatRoutes } from "./auth-compat-routes.js"; import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
try { try {
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) { if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
authLogger.warn( authLogger.warn(
"Rejected proxy authentication from an untrusted source", `Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
{ {
operation: "trusted_proxy_auth_rejected", operation: "trusted_proxy_auth_rejected",
sourceAddress, sourceAddress,
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
} }
}); });
/** registerUserDeleteAccountRoutes(router, authenticateJWT);
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRecord = await findCurrentUser(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin) {
const adminCount = await createCurrentUserRepository().countAdmins();
if (adminCount <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
}
await createCurrentUserRepository().delete(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
registerUserPasswordResetRoutes(router, { authManager }); registerUserPasswordResetRoutes(router, { authManager });
@@ -7,7 +7,10 @@
*/ */
import type { Duplex } from "node:stream"; import type { Duplex } from "node:stream";
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend"; import type {
PluginSshTransportHost,
PluginSshTransportProvider,
} from "@termix-ssh/plugin-sdk/backend";
interface Entry { interface Entry {
pluginId: string; pluginId: string;
@@ -33,15 +36,13 @@ export function clearSshTransports(): void {
} }
/** Opens a stream to the host if a provider claims it, else null. */ /** Opens a stream to the host if a provider claims it, else null. */
export async function openProvidedTransport(host: { export async function openProvidedTransport(
id: number; host: PluginSshTransportHost,
ip: string; ): Promise<Duplex | null> {
port: number;
}): Promise<Duplex | null> {
for (const { provider } of providers.values()) { for (const { provider } of providers.values()) {
let claimed = false; let claimed = false;
try { try {
claimed = provider.claims(host.id); claimed = provider.claims(host.id, host);
} catch { } catch {
claimed = false; claimed = false;
} }
+4
View File
@@ -99,6 +99,10 @@ export async function openSshTransport(
id: host.id, id: host.id,
ip: host.ip, ip: host.ip,
port: host.port || 22, port: host.port || 22,
userId: host.userId ?? null,
syncId: host.syncId ?? null,
connectionOrigin: host.connectionOrigin ?? null,
localOnly: Boolean(host.localOnly),
}); });
if (provided) { if (provided) {
config.sock = provided; config.sock = provided;
+3
View File
@@ -24,6 +24,9 @@ export interface SshConnectHost {
port: number; port: number;
username: string; username: string;
userId?: string | null; userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean | null;
authType?: string | null; authType?: string | null;
password?: string | null; password?: string | null;
key?: string | Buffer | null; key?: string | Buffer | null;
+12 -5
View File
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
...recipientHost, ...recipientHost,
authOverrides: { authOverrides: {
...authOverrides, ...authOverrides,
ssh: { ssh: resolution.folder
credentialId: resolution.credentialId, ? {
required: false, required: false,
ownerAuthShared: !!host.shareSshAuth, ownerAuthShared: !!host.shareSshAuth,
}, folder: resolution.folder,
folderCredentialId: resolution.credentialId,
}
: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
}, },
authType: authType:
credential.key || credential.privateKey credential.key || credential.privateKey
+11 -7
View File
@@ -13,7 +13,7 @@ import net from "node:net";
import type { Duplex } from "node:stream"; import type { Duplex } from "node:stream";
import { createWebSocketStream, type WebSocket } from "ws"; import { createWebSocketStream, type WebSocket } from "ws";
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend"; import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
import { resolveHostById } from "./host-resolver.js"; import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js";
import { openSshTransport } from "./connect/transport.js"; import { openSshTransport } from "./connect/transport.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js"; import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
import { sshLogger } from "../utils/logger.js"; import { sshLogger } from "../utils/logger.js";
@@ -35,12 +35,13 @@ async function handleRelay(
): Promise<void> { ): Promise<void> {
const ws = connection.socket as WebSocket; const ws = connection.socket as WebSocket;
const url = String((connection.request as { url?: string }).url ?? ""); const url = String((connection.request as { url?: string }).url ?? "");
const hostId = Number( const params = new URL(url, "http://relay").searchParams;
new URL(url, "http://relay").searchParams.get("hostId"), const hostId = Number(params.get("hostId"));
); // A linked desktop knows its hosts by sync id, not by this server's id.
const syncId = params.get("syncId");
const userId = connection.userId; const userId = connection.userId;
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) { if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
close(ws, 1008, "A host is required"); close(ws, 1008, "A host is required");
return; return;
} }
@@ -49,7 +50,9 @@ async function handleRelay(
return; return;
} }
const host = await resolveHostById(hostId, userId); const host = syncId
? await resolveHostBySyncId(syncId, userId)
: await resolveHostById(hostId, userId);
if (!host) { if (!host) {
close(ws, 1008, "Host not found or access denied"); close(ws, 1008, "Host not found or access denied");
return; return;
@@ -93,7 +96,8 @@ async function handleRelay(
} catch (error) { } catch (error) {
sshLogger.warn("A relay could not reach its host", { sshLogger.warn("A relay could not reach its host", {
operation: "ssh_relay_open", operation: "ssh_relay_open",
hostId, hostId: syncId ? undefined : hostId,
syncId,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
finish(); finish();
+20 -3
View File
@@ -38,6 +38,20 @@ export function isUserPluginFile(
); );
} }
/** Whether a failed import from a plugin should try core's copy. */
export function shouldUseHostModule(
error: unknown,
specifier: string,
parentURL: string | undefined,
pluginsDir: string,
): boolean {
const code = (error as { code?: string })?.code;
// import() says ERR_MODULE_NOT_FOUND, a CommonJS require MODULE_NOT_FOUND.
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
return false;
return isBareSpecifier(specifier) && isUserPluginFile(parentURL, pluginsDir);
}
export function installHostModuleFallback(): void { export function installHostModuleFallback(): void {
if (installed) return; if (installed) return;
installed = true; installed = true;
@@ -49,9 +63,12 @@ export function installHostModuleFallback(): void {
return nextResolve(specifier, context); return nextResolve(specifier, context);
} catch (error) { } catch (error) {
if ( if (
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" || !shouldUseHostModule(
!isBareSpecifier(specifier) || error,
!isUserPluginFile(context.parentURL, getPluginsDir()) specifier,
context.parentURL,
getPluginsDir(),
)
) { ) {
throw error; throw error;
} }
+64 -9
View File
@@ -73,7 +73,7 @@ import {
pluginFeatures, pluginFeatures,
type PluginManifest, type PluginManifest,
} from "./manifest.js"; } from "./manifest.js";
import { requireSignedPlugins } from "./trust.js"; import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
import { import {
parseChangelog, parseChangelog,
parseReleaseNotes, parseReleaseNotes,
@@ -1048,7 +1048,15 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
await readUninstalled(), await readUninstalled(),
); );
const restored: LoadedPlugin[] = []; const restored: LoadedPlugin[] = [];
for (const id of ids) { // Shown on the Plugins tab, so a plugin that could not be fetched is not
// listed as working. It stays on and is tried again on the next start.
const markMissing = (id: string, message: string) =>
recordState(
id,
"failed",
`Not in this image and could not be downloaded: ${message}`,
).catch(() => {});
for (const [index, id] of ids.entries()) {
try { try {
const record = await repository.findById(id); const record = await repository.findById(id);
const { source, release } = await requireRelease( const { source, release } = await requireRelease(
@@ -1075,11 +1083,14 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
pluginLogger.warn(`Could not restore ${id}: ${message}`, { pluginLogger.warn(`Could not restore ${id}: ${message}`, {
operation: "plugin_restore_missing", operation: "plugin_restore_missing",
}); });
await markMissing(id, message);
// The registry is down or offline, so the rest would fail the same way. // The registry is down or offline, so the rest would fail the same way.
if ( if (
error instanceof PluginManageError && error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE" error.code === "REGISTRY_UNREACHABLE"
) { ) {
for (const rest of ids.slice(index + 1))
await markMissing(rest, message);
break; break;
} }
} }
@@ -1514,6 +1525,8 @@ interface StagedUpload {
file: string; file: string;
manifest: PluginManifest; manifest: PluginManifest;
expiresAt: number; expiresAt: number;
/** Set when the upload came with a .sig an official key made. */
official: boolean;
} }
const uploads = new Map<string, StagedUpload>(); const uploads = new Map<string, StagedUpload>();
@@ -1537,6 +1550,29 @@ export interface UploadPreview {
capabilities: string[]; capabilities: string[];
/** The version of an earlier upload this one replaces. */ /** The version of an earlier upload this one replaces. */
replaces: string | null; replaces: string | null;
/** The .sig sent with it was made by the official Termix key. */
official: boolean;
}
/**
* Checks a .sig sent with an upload. An official signature makes the file
* the same as a registry download, so it may stand in for one that could not
* be fetched (an offline install). Anything else is refused.
*/
function checkUploadSignature(
buffer: Buffer,
signature: string,
): { official: boolean } {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyAnyTrusted(buffer, sha256, signature);
if (result.ok === false) {
throw new PluginManageError(
`The signature does not match this file: ${result.reason}`,
400,
"INVALID_SIGNATURE",
);
}
return { official: result.tier === "official" };
} }
async function assertUploadsAllowed(): Promise<void> { async function assertUploadsAllowed(): Promise<void> {
@@ -1593,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
return manifest; return manifest;
} }
/** Only another upload of the same id can be replaced from a file. */ /**
async function assertReplaceable(pluginId: string): Promise<string | null> { * Only another upload of the same id can be replaced from a file. An
* officially signed file may also fill in a plugin whose code is missing, like
* one that was on but could not be downloaded.
*/
async function assertReplaceable(
pluginId: string,
official = false,
): Promise<string | null> {
const { loader } = getPluginRuntime(); const { loader } = getPluginRuntime();
if (official && !loader.get(pluginId)) return null;
if (loader.bundledIds().has(pluginId)) { if (loader.bundledIds().has(pluginId)) {
throw new PluginManageError( throw new PluginManageError(
`${pluginId} ships with Termix and can only be replaced by a signed release`, `${pluginId} ships with Termix and can only be replaced by a signed release`,
@@ -1620,21 +1664,30 @@ async function assertReplaceable(pluginId: string): Promise<string | null> {
* Checks an uploaded .tmxplug and holds it until the admin agrees to what * Checks an uploaded .tmxplug and holds it until the admin agrees to what
* it asks for. Nothing is loaded yet. * it asks for. Nothing is loaded yet.
*/ */
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> { export async function stageUpload(
buffer: Buffer,
signature: string | null = null,
): Promise<UploadPreview> {
await assertUploadsAllowed(); await assertUploadsAllowed();
await clearUploads(Date.now()); await clearUploads(Date.now());
const manifest = validateUpload(buffer); const manifest = validateUpload(buffer);
const replaces = await assertReplaceable(manifest.id); const { official } = signature
? checkUploadSignature(buffer, signature)
: { official: false };
const replaces = await assertReplaceable(manifest.id, official);
const token = crypto.randomBytes(16).toString("hex"); const token = crypto.randomBytes(16).toString("hex");
const dir = getDownloadsDir(); const dir = getDownloadsDir();
await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `upload-${token}.tmxplug`); const file = path.join(dir, `upload-${token}.tmxplug`);
await fs.promises.writeFile(file, buffer); await fs.promises.writeFile(file, buffer);
// Next to the file, where the loader looks for it.
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
uploads.set(token, { uploads.set(token, {
file, file,
manifest, manifest,
expiresAt: Date.now() + UPLOAD_TTL_MS, expiresAt: Date.now() + UPLOAD_TTL_MS,
official,
}); });
return { return {
@@ -1647,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
features: pluginFeatures(manifest.features), features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities], capabilities: [...manifest.capabilities],
replaces, replaces,
official,
}; };
} }
@@ -1676,7 +1730,7 @@ export function installUpload(
); );
} }
assertConsent(options.capabilities, manifest.capabilities); assertConsent(options.capabilities, manifest.capabilities);
await assertReplaceable(pluginId); await assertReplaceable(pluginId, upload.official);
const { createCurrentPluginRepository } = await repos(); const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository(); const repository = createCurrentPluginRepository();
@@ -1689,6 +1743,7 @@ export function installUpload(
plugin = await swapIn(pluginId, { upload: upload.file }); plugin = await swapIn(pluginId, { upload: upload.file });
} catch (error) { } catch (error) {
await fs.promises.rm(upload.file, { force: true }); await fs.promises.rm(upload.file, { force: true });
await fs.promises.rm(`${upload.file}.sig`, { force: true });
const { loader } = getPluginRuntime(); const { loader } = getPluginRuntime();
if (before && wasEnabled && loader.get(pluginId)) { if (before && wasEnabled && loader.get(pluginId)) {
await startAndRecord(pluginId); await startAndRecord(pluginId);
@@ -1701,7 +1756,7 @@ export function installUpload(
} }
await repository.update(pluginId, { await repository.update(pluginId, {
tier: UNVERIFIED_TIER, tier: upload.official ? "official" : UNVERIFIED_TIER,
registryId: null, registryId: null,
pinnedVersion: null, pinnedVersion: null,
autoUpdate: false, autoUpdate: false,
@@ -1725,7 +1780,7 @@ export function installUpload(
} }
const state = await startAndRecord(pluginId); const state = await startAndRecord(pluginId);
pluginLogger.warn( pluginLogger.warn(
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`, `Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
{ operation: "plugin_install" }, { operation: "plugin_install" },
); );
return { id: pluginId, version: manifest.version, state }; return { id: pluginId, version: manifest.version, state };
+50 -2
View File
@@ -16,7 +16,7 @@ import type {
SyncRow, SyncRow,
} from "@termix-ssh/plugin-sdk/backend"; } from "@termix-ssh/plugin-sdk/backend";
/** Core-only extras a plugin registration cannot set. */ /** Extras core sets for its own entities. A plugin may set the first four. */
export interface CoreSyncEntityOptions { export interface CoreSyncEntityOptions {
/** Server-to-desktop only. A desktop never pushes it. */ /** Server-to-desktop only. A desktop never pushes it. */
readOnly?: boolean; readOnly?: boolean;
@@ -58,6 +58,54 @@ export interface RegisteredSyncEntity
encryptedFields: readonly string[]; encryptedFields: readonly string[];
} }
/** What a plugin registration may carry. Anything else is dropped. */
const PLUGIN_KEYS = [
"type",
"table",
"userColumn",
"encryptedFields",
"references",
"order",
"readOnlyFields",
"singleton",
"answersTo",
"shouldSync",
"permissions",
"serialize",
"deserialize",
"afterWrite",
"remove",
"readOnly",
"load",
"write",
"erase",
] as const;
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
const picked: Record<string, unknown> = {};
for (const key of PLUGIN_KEYS) {
if (entity[key] !== undefined) picked[key] = entity[key];
}
const out = picked as unknown as SyncEntityRegistration;
if (out.load || out.write || out.erase) {
if (!out.load || !out.write || !out.erase) {
throw new Error(
`Sync entity "${entity.type}" needs load, write and erase together`,
);
}
// A plugin only ever syncs its own rows both ways; rows it builds for
// a user come down as read only copies.
if (out.readOnly !== true) {
throw new Error(
`Sync entity "${entity.type}" with load must be readOnly`,
);
}
} else if (!out.table) {
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
}
return out;
}
const entities = new Map<string, RegisteredSyncEntity>(); const entities = new Map<string, RegisteredSyncEntity>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -81,7 +129,7 @@ export function registerEntity(
} }
const registered: RegisteredSyncEntity = { const registered: RegisteredSyncEntity = {
...entity, ...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
...(owner === CORE_OWNER ? core : {}), ...(owner === CORE_OWNER ? core : {}),
owner, owner,
order: entity.order ?? 100, order: entity.order ?? 100,
+6
View File
@@ -38,6 +38,7 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
import { import {
createResolvers, createResolvers,
deleteStoredRow, deleteStoredRow,
findStoredIds,
listStoredRows, listStoredRows,
loadWireRow, loadWireRow,
loadWireRows, loadWireRows,
@@ -271,6 +272,11 @@ export function createSyncHost(): PluginSyncHost {
ensureCore: () => ensureCoreEntities(), ensureCore: () => ensureCoreEntities(),
onChanged: (listener) => onEntitiesChanged(listener), onChanged: (listener) => onEntitiesChanged(listener),
onLocalWrite: (listener) => onLocalWrite(listener), onLocalWrite: (listener) => onLocalWrite(listener),
idsOf: async (type, userId, syncIds) => {
const entity = getEntity(type);
if (!entity || entity.load) return {};
return findStoredIds(entity, userId, syncIds);
},
}, },
replica: { replica: {
+1 -1
View File
@@ -161,7 +161,7 @@ async function reconcileListeners(): Promise<void> {
} }
} }
function notifyListeners(userId: string): void { export function notifyListeners(userId: string): void {
const set = listeners.get(userId); const set = listeners.get(userId);
if (!set) return; if (!set) return;
for (const res of set) { for (const res of set) {
+7 -1
View File
@@ -25,7 +25,7 @@ import {
} from "../store.js"; } from "../store.js";
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js"; import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
import { orderSelfReferences } from "../wire.js"; import { orderSelfReferences } from "../wire.js";
import { reconcileUser } from "./feed.js"; import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
export interface PushOp { export interface PushOp {
entityType: string; entityType: string;
@@ -278,6 +278,12 @@ export async function applyPush(
// Refresh the snapshot so the next pull serves what was just written. // Refresh the snapshot so the next pull serves what was just written.
await reconcileUser(userId, { force: true }); await reconcileUser(userId, { force: true });
// The reconcile finds nothing new, since the push wrote the records itself,
// so tell this user's other clients, and other users who may share a row.
if ([...results.values()].some((result) => result.status === "ok")) {
notifyListeners(userId);
markChanged();
}
return ops.map( return ops.map(
(op) => (op) =>
results.get(recordKey(op.entityType, op.syncId)) ?? { results.get(recordKey(op.entityType, op.syncId)) ?? {
+15
View File
@@ -112,6 +112,21 @@ export async function findStoredRow(
return row ?? null; return row ?? null;
} }
/** The local ids of the user's own rows, by sync id. Unknown ones are left out. */
export async function findStoredIds(
entity: RegisteredSyncEntity,
userId: string,
syncIds: string[],
): Promise<Record<string, number>> {
const ids: Record<string, number> = {};
if (entity.singleton || !entity.table) return ids;
for (const syncId of new Set(syncIds)) {
const row = await findStoredRow(entity, userId, syncId);
if (row && typeof row.id === "number") ids[syncId] = row.id;
}
return ids;
}
/** Gives an old row with no syncId one, so it can take part. */ /** Gives an old row with no syncId one, so it can take part. */
async function ensureSyncId( async function ensureSyncId(
entity: RegisteredSyncEntity, entity: RegisteredSyncEntity,
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
folderAndParents,
SharedFolderAuthOverrideRepository,
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
import { TestSqliteDatabase } from "./test-support.js";
describe("folderAndParents", () => {
it("lists the folder, then each folder above it", () => {
expect(folderAndParents("Prod / Web / EU")).toEqual([
"Prod / Web / EU",
"Prod / Web",
"Prod",
]);
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
expect(folderAndParents("")).toEqual([]);
expect(folderAndParents(null)).toEqual([]);
});
});
describe("SharedFolderAuthOverrideRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
await adapter?.close();
adapter = null;
});
async function createRepository(onWrite?: () => void) {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (7, 'recipient', 'cred-seven', 'password'),
(8, 'recipient', 'cred-eight', 'password');
`);
return new SharedFolderAuthOverrideRepository(context, onWrite);
}
it("sets, updates and clears a login per user, folder and protocol", async () => {
let writes = 0;
const repository = await createRepository(() => {
writes += 1;
});
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod", "ssh", 8);
await repository.setCredential("recipient", "Prod", "rdp", 7);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBe(8);
await expect(
repository.findCredentialId("other", "Prod", "ssh"),
).resolves.toBeNull();
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(true);
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(false);
expect(writes).toBe(4);
});
it("finds the login of the closest folder above a host", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
await expect(
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
await expect(
repository.findClosest("recipient", "Prod / DB", "ssh"),
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
await expect(
repository.findClosest("recipient", "Staging", "ssh"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", "Prod", "rdp"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", null, "ssh"),
).resolves.toBeNull();
});
it("drops the login when its credential is deleted", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBeNull();
});
});
@@ -8,9 +8,14 @@ const state = vi.hoisted(() => ({
isShared: true, isShared: true,
isAdminBypass: false, isAdminBypass: false,
}, },
credentialOwned: true, credentialUsable: true,
credentialId: 7 as number | null, credentialId: 7 as number | null,
writes: [] as Array<{ protocol: string; credentialId: number | null }>, writes: [] as Array<{ protocol: string; credentialId: number | null }>,
folderWrites: [] as Array<{
folder: string;
protocol: string;
credentialId: number | null;
}>,
auditCalls: [] as Array<Record<string, unknown>>, auditCalls: [] as Array<Record<string, unknown>>,
})); }));
@@ -70,11 +75,12 @@ vi.mock("../../../utils/permission-manager.js", () => ({
}, },
})); }));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentialUsable ? { id: credentialId } : null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
findByIdForUser: async () =>
state.credentialOwned ? { id: state.credentialId } : null,
}),
createCurrentSharedHostAuthOverrideRepository: () => ({ createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.credentialId, findCredentialId: async () => state.credentialId,
setCredential: async ( setCredential: async (
@@ -96,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
return true; return true;
}, },
}), }),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findCredentialId: async () => 7,
setCredential: async (
_userId: string,
folder: string,
protocol: string,
credentialId: number,
) => {
state.folderWrites.push({ folder, protocol, credentialId });
},
clearCredential: async (
_userId: string,
folder: string,
protocol: string,
) => {
state.folderWrites.push({ folder, protocol, credentialId: null });
return true;
},
}),
createCurrentUserRepository: () => ({ createCurrentUserRepository: () => ({
findById: async () => ({ id: "recipient", username: "recipient" }), findById: async () => ({ id: "recipient", username: "recipient" }),
}), }),
@@ -108,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
vi.mock("../../../utils/audit-logger.js", () => ({ vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
getAuditUsername: async (userId: string) => userId,
logAudit: vi.fn(async (entry: Record<string, unknown>) => { logAudit: vi.fn(async (entry: Record<string, unknown>) => {
state.auditCalls.push(entry); state.auditCalls.push(entry);
}), }),
@@ -133,6 +159,8 @@ describe("shared host authentication override routes", () => {
method: "get" | "put", method: "get" | "put",
body: Record<string, unknown> = {}, body: Record<string, unknown> = {},
protocol = "ssh", protocol = "ssh",
path = "/host-access/:hostId/auth/:protocol",
query: Record<string, unknown> = {},
): Promise<{ status: number; body: unknown }> { ): Promise<{ status: number; body: unknown }> {
const routeLayer = ( const routeLayer = (
router as unknown as { router as unknown as {
@@ -151,15 +179,14 @@ describe("shared host authentication override routes", () => {
}>; }>;
} }
).stack.find( ).stack.find(
(layer) => (layer) => layer.route?.path === path && layer.route.methods[method],
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
layer.route.methods[method],
); );
if (!routeLayer?.route) throw new Error(`Missing ${method} route`); if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
const handlers = routeLayer.route.stack.map((layer) => layer.handle); const handlers = routeLayer.route.stack.map((layer) => layer.handle);
const req = { const req = {
params: { hostId: "42", protocol }, params: { hostId: "42", protocol },
query,
body, body,
headers: {}, headers: {},
ip: "127.0.0.1", ip: "127.0.0.1",
@@ -206,12 +233,106 @@ describe("shared host authentication override routes", () => {
isShared: true, isShared: true,
isAdminBypass: false, isAdminBypass: false,
}; };
state.credentialOwned = true; state.credentialUsable = true;
state.credentialId = 7; state.credentialId = 7;
state.writes = []; state.writes = [];
state.folderWrites = [];
state.auditCalls = []; state.auditCalls = [];
}); });
describe("folder logins", () => {
const FOLDER = "/folder-auth/:protocol";
it("reads the login for a folder", async () => {
const response = await invoke("get", {}, "ssh", FOLDER, {
folder: " Prod / Web ",
});
expect(response).toEqual({
status: 200,
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
});
});
it("sets and clears the login and audits both", async () => {
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: null },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(state.folderWrites).toEqual([
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
{ folder: "Prod", protocol: "ssh", credentialId: null },
]);
expect(state.auditCalls.map((call) => call.action)).toEqual([
"set_shared_folder_auth_override",
"clear_shared_folder_auth_override",
]);
});
it("rejects a bad folder, protocol or credential", async () => {
expect(
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "x".repeat(300), credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"smtp",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: -1 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
state.credentialUsable = false;
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 99 },
"ssh",
FOLDER,
)
).status,
).toBe(404);
expect(state.folderWrites).toEqual([]);
});
});
it("returns the current override for a role-derived shared recipient", async () => { it("returns the current override for a role-derived shared recipient", async () => {
const response = await invoke("get"); const response = await invoke("get");
expect(response).toEqual({ expect(response).toEqual({
@@ -254,7 +375,7 @@ describe("shared host authentication override routes", () => {
const invalidResponse = await invoke("put", { credentialId: 0 }); const invalidResponse = await invoke("put", { credentialId: 0 });
expect(invalidResponse.status).toBe(400); expect(invalidResponse.status).toBe(400);
state.credentialOwned = false; state.credentialUsable = false;
const foreignResponse = await invoke("put", { credentialId: 99 }); const foreignResponse = await invoke("put", { credentialId: 99 });
expect(foreignResponse.status).toBe(404); expect(foreignResponse.status).toBe(404);
@@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}), }),
})); }));
const { pruneHostGrants, pruneCredentialGrants } = const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } =
await import("../../../database/routes/rbac.js"); await import("../../../database/routes/rbac.js");
const chain = (): Grant[] => [ const chain = (): Grant[] => [
@@ -105,3 +105,27 @@ describe("share pruning", () => {
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]); expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
}); });
}); });
describe("grantsForFolderRule", () => {
const entries = [
{ id: 1, userId: "bob", roleId: null },
{ id: 2, userId: null, roleId: 7 },
{ id: 3, userId: "carol", roleId: null },
];
it("picks the grants of the rule's user or role", () => {
expect(
grantsForFolderRule(entries, { userId: "bob", roleId: null }).map(
(e) => e.id,
),
).toEqual([1]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: 7 }).map(
(e) => e.id,
),
).toEqual([2]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: null }),
).toEqual([]);
});
});
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash: string;
}
>(),
adminCount: 1,
deleted: [] as string[],
}));
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("bcryptjs", () => ({
default: {
compare: async (password: string, hash: string) =>
hash === `hash:${password}`,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
countAdmins: async () => state.adminCount,
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
state.deleted.push(userId);
},
}));
const { registerUserDeleteAccountRoutes } =
await import("../../../database/routes/user-delete-account-routes.js");
let handler: RequestHandler | null = null;
registerUserDeleteAccountRoutes(
{
delete: (_path: string, ...handlers: RequestHandler[]) => {
handler = handlers[handlers.length - 1];
},
} as unknown as import("express").Router,
(_req, _res, next) => next(),
);
async function deleteAccount(userId: string, body: Record<string, unknown>) {
const req = { userId, body } as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as Record<string, unknown> | null,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: Record<string, unknown>) {
this.jsonBody = payload;
return this;
},
};
await handler!(req, res as unknown as Response, () => {});
return res;
}
beforeEach(() => {
state.adminCount = 1;
state.deleted = [];
state.users = new Map([
[
"u1",
{
id: "u1",
username: "user",
isAdmin: false,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"sso1",
{
id: "sso1",
username: "sso",
isAdmin: false,
isExternal: true,
passwordHash: "hash:secret",
},
],
]);
});
describe("DELETE /delete-account", () => {
it("runs the full user cleanup", async () => {
const res = await deleteAccount("u1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["u1"]);
});
it("needs a password", async () => {
const res = await deleteAccount("u1", {});
expect(res.statusCode).toBe(400);
expect(state.deleted).toEqual([]);
});
it("refuses a wrong password", async () => {
const res = await deleteAccount("u1", { password: "nope" });
expect(res.statusCode).toBe(401);
expect(state.deleted).toEqual([]);
});
it("refuses external accounts", async () => {
const res = await deleteAccount("sso1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("refuses the last admin", async () => {
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("lets an admin go when another admin is left", async () => {
state.adminCount = 2;
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["admin1"]);
});
it("404s for an unknown user", async () => {
const res = await deleteAccount("ghost", { password: "secret" });
expect(res.statusCode).toBe(404);
});
});
@@ -37,6 +37,29 @@ afterEach(() => {
}); });
describe("ssh transport providers", () => { describe("ssh transport providers", () => {
it("tells providers the owner, sync id and origin of the host", async () => {
const claims = vi.fn(() => false);
provideSshTransport("a", { claims, open: vi.fn() });
await openSshTransport(
{
...host,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
} as never,
{} as never,
).catch(() => {});
expect(claims).toHaveBeenCalledWith(7, {
id: 7,
ip: "10.0.0.7",
port: 22,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
localOnly: false,
});
});
it("opens nothing when no provider claims the host", async () => { it("opens nothing when no provider claims the host", async () => {
provideSshTransport("a", { claims: () => false, open: vi.fn() }); provideSshTransport("a", { claims: () => false, open: vi.fn() });
expect(await openProvidedTransport(host)).toBeNull(); expect(await openProvidedTransport(host)).toBeNull();
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
isAdminBypass: false, isAdminBypass: false,
overrideCredentialId: null as number | null, overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(), credentials: new Map<string, Record<string, unknown>>(),
sharedCredentials: new Map<string, Record<string, unknown>>(),
auditCalls: [] as Record<string, unknown>[], auditCalls: [] as Record<string, unknown>[],
sharedSecret: null as Record<string, unknown> | null, sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null, activeHostAccessId: null as number | null,
@@ -37,6 +38,13 @@ vi.mock("../../database/repositories/factory.js", () => ({
}), }),
})); }));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ??
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
null,
}));
vi.mock("../../utils/audit-logger.js", () => ({ vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => { logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params); state.auditCalls.push(params);
@@ -125,6 +133,7 @@ beforeEach(() => {
state.isAdminBypass = false; state.isAdminBypass = false;
state.overrideCredentialId = null; state.overrideCredentialId = null;
state.credentials.clear(); state.credentials.clear();
state.sharedCredentials.clear();
state.auditCalls = []; state.auditCalls = [];
state.sharedSecret = null; state.sharedSecret = null;
state.activeHostAccessId = null; state.activeHostAccessId = null;
@@ -376,6 +385,61 @@ describe("resolveHostById", () => {
expect(host.username).toBe("shared-login"); expect(host.username).toBe("shared-login");
}); });
it("uses a credential shared with the recipient as their personal override", async () => {
state.overrideCredentialId = 7;
state.sharedCredentials.set("7:recipient", {
id: 7,
username: "shared-cred-user",
password: "shared-cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.username).toBe("shared-cred-user");
expect(host.password).toBe("shared-cred-pass");
});
it("uses the host's credential when it is also shared with the recipient", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
state.sharedCredentials.set("1:recipient", {
id: 1,
username: "deploy",
password: null,
privateKey: "OWNER-KEY-COPY",
key: null,
keyPassword: null,
keyType: "ssh-ed25519",
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.authType).toBe("key");
expect(host.key).toBe("OWNER-KEY-COPY");
expect(host.username).toBe("deploy");
});
it("denies a recipient when the host's credential is not shared with them", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
expect(await resolveHostById(42, "recipient")).toBeNull();
});
it("denies a non-owner when a secret-bearing host has no personal credential", async () => { it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
expect(await resolveHostById(42, "recipient")).toBeNull(); expect(await resolveHostById(42, "recipient")).toBeNull();
}); });
+19
View File
@@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
resolveHostById: vi.fn(), resolveHostById: vi.fn(),
resolveHostBySyncId: vi.fn(),
openSshTransport: vi.fn(), openSshTransport: vi.fn(),
handler: null as null | ((connection: unknown) => Promise<void>), handler: null as null | ((connection: unknown) => Promise<void>),
})); }));
@@ -17,6 +18,7 @@ vi.mock("../../utils/logger.js", () => {
}); });
vi.mock("../../hosts/host-resolver.js", () => ({ vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById, resolveHostById: mocks.resolveHostById,
resolveHostBySyncId: mocks.resolveHostBySyncId,
})); }));
vi.mock("../../hosts/connect/transport.js", () => ({ vi.mock("../../hosts/connect/transport.js", () => ({
openSshTransport: mocks.openSshTransport, openSshTransport: mocks.openSshTransport,
@@ -114,6 +116,23 @@ describe("ssh relay", () => {
await vi.waitFor(() => expect(end).toHaveBeenCalled()); await vi.waitFor(() => expect(end).toHaveBeenCalled());
}); });
it("finds the host by sync id for a linked desktop", async () => {
mocks.resolveHostBySyncId.mockResolvedValue({
id: 9,
ip: "127.0.0.1",
port: echoPort,
});
const { socket } = await open("?syncId=abc");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send("ping");
expect(await received).toBe("ping");
expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1");
expect(mocks.resolveHostById).not.toHaveBeenCalled();
socket.close();
});
it("refuses a host the user can't connect to", async () => { it("refuses a host the user can't connect to", async () => {
mocks.resolveHostById.mockResolvedValue(null); mocks.resolveHostById.mockResolvedValue(null);
const { closed } = await open("?hostId=5"); const { closed } = await open("?hostId=5");
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
parsePlatformProbe, parsePlatformProbe,
@@ -7,6 +8,7 @@ import {
buildSudoCommand, buildSudoCommand,
parseUpgradable, parseUpgradable,
execCommand, execCommand,
decodeCommandOutput,
} from "@termix-ssh/plugin-sdk/host-commands"; } from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => { describe("parsePlatformProbe", () => {
@@ -116,6 +118,32 @@ describe("parseUpgradable", () => {
}); });
}); });
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
destroy() {}
}
function fakeClient(script: (stream: FakeStream) => void) {
return {
exec: (
_command: string,
_options: unknown,
callback: (err: Error | undefined, stream: FakeStream) => void,
) => {
const stream = new FakeStream();
callback(undefined, stream);
script(stream);
},
} as never;
}
// "The system cannot find the path specified." as the GBK bytes a
// Chinese-locale Windows console actually sends.
const GBK_PATH_ERROR = Buffer.from(
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
"hex",
);
describe("execCommand", () => { describe("execCommand", () => {
it("leaves the command out of the timeout error", async () => { it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never; const client = { exec: () => {} } as never;
@@ -124,4 +152,64 @@ describe("execCommand", () => {
/^Command timeout after 5ms$/, /^Command timeout after 5ms$/,
); );
}); });
it("decodes GBK stderr from a localized Windows host", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
stream.emit("close", 1);
}),
"dir",
);
expect(result.stderr).toBe("系统找不到指定的路径。");
expect(result.code).toBe(1);
});
it("reassembles a UTF-8 character split across chunks", async () => {
const utf8 = Buffer.from("以太网 2", "utf8");
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
stream.emit("data", utf8.subarray(4));
stream.emit("close", 0);
}),
"ipconfig",
);
expect(result.stdout).toBe("以太网 2");
});
it("passes plain ASCII output through unchanged", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", Buffer.from("ok\n"));
stream.stderr.emit("data", Buffer.from("warn\n"));
stream.emit("close", 0);
}),
"echo ok",
);
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
});
});
describe("decodeCommandOutput", () => {
it("keeps valid UTF-8, including the empty buffer", () => {
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK when the bytes are not valid UTF-8", () => {
// "以太网 2" in GBK, the interface name of the metrics widget issue.
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
});
it("keeps lossy UTF-8 when no decoder matches", () => {
// 0xff is a valid byte in neither UTF-8 nor GBK.
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
});
}); });
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { import {
isBareSpecifier, isBareSpecifier,
isUserPluginFile, isUserPluginFile,
shouldUseHostModule,
} from "../../plugins/host-modules.js"; } from "../../plugins/host-modules.js";
describe("isBareSpecifier", () => { describe("isBareSpecifier", () => {
@@ -46,6 +47,36 @@ describe("isUserPluginFile", () => {
}); });
}); });
describe("shouldUseHostModule", () => {
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
const parent = pathToFileURL(
path.join(root, ".unpacked", "demo", "dist", "backend.js"),
).href;
it("falls back for import and require misses from a plugin", () => {
for (const code of ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]) {
expect(shouldUseHostModule({ code }, "axios", parent, root)).toBe(true);
}
});
it("leaves other errors and files alone", () => {
expect(
shouldUseHostModule({ code: "ERR_REQUIRE_ESM" }, "axios", parent, root),
).toBe(false);
expect(
shouldUseHostModule({ code: "MODULE_NOT_FOUND" }, "./x.js", parent, root),
).toBe(false);
expect(
shouldUseHostModule(
{ code: "MODULE_NOT_FOUND" },
"axios",
undefined,
root,
),
).toBe(false);
});
});
describe("installHostModuleFallback", () => { describe("installHostModuleFallback", () => {
let dataDir: string | null = null; let dataDir: string | null = null;
+51
View File
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER); expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
}); });
it("refuses a signature that does not match the file", async () => {
await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
});
it("keeps a signature from a non official key unverified", async () => {
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
try {
await boot();
await manage.setDeveloperMode(true);
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
const buffer = await artifactBuffer("notes");
const digest = crypto.createHash("sha256").update(buffer).digest();
const signature = crypto
.sign(null, digest, privateKey)
.toString("base64");
const preview = await manage.stageUpload(buffer, signature);
expect(preview.official).toBe(false);
await manage.installUpload(preview.token, {
userId: "admin",
capabilities: preview.capabilities,
});
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
} finally {
setCustomPluginKeys([]);
}
});
it("refuses a file that is not a plugin archive", async () => { it("refuses a file that is not a plugin archive", async () => {
await boot(); await boot();
await manage.setDeveloperMode(true); await manage.setDeveloperMode(true);
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
expect(getEntity("thing")?.readOnly).toBeUndefined(); expect(getEntity("thing")?.readOnly).toBeUndefined();
expect(getEntity("thing")?.load).toBeUndefined(); expect(getEntity("thing")?.load).toBeUndefined();
}); });
it("lets a plugin sync rows it builds as read only copies", () => {
const load = async () => [];
registerEntity("demo", {
type: "sharedIcons",
table: null,
readOnly: true,
load,
write: async () => undefined,
erase: async () => undefined,
});
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
});
it("refuses a plugin entity that loads rows but can be pushed", () => {
expect(() =>
registerEntity("demo", {
type: "pushable",
table: null,
load: async () => [],
write: async () => undefined,
erase: async () => undefined,
}),
).toThrow(/readOnly/);
expect(() =>
registerEntity("demo", {
type: "halfDone",
table: null,
readOnly: true,
load: async () => [],
}),
).toThrow(/load, write and erase/);
expect(() =>
registerEntity("demo", { type: "nothing", table: null }),
).toThrow(/table or load/);
});
it("drops core only keys a plugin puts on its registration", () => {
registerEntity("demo", {
type: "sneaky",
table: {},
covers: () => true,
canReference: async () => true,
hashIgnore: ["x"],
} as never);
const entity = getEntity("sneaky")!;
expect(entity.covers).toBeUndefined();
expect(entity.canReference).toBeUndefined();
expect(entity.hashIgnore).toBeUndefined();
});
}); });
describe("core sync entities", () => { describe("core sync entities", () => {
+25 -2
View File
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
getLink: vi.fn(), getLink: vi.fn(),
generateJWTToken: vi.fn(), generateJWTToken: vi.fn(),
revokeSession: vi.fn(), revokeSession: vi.fn(),
getEntity: vi.fn(),
findStoredIds: vi.fn(),
})); }));
vi.mock("../../utils/auth-manager.js", () => ({ vi.mock("../../utils/auth-manager.js", () => ({
@@ -28,7 +30,7 @@ vi.mock("../../utils/app-version.js", () => ({
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() })); vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() })); vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../plugins/sync-registry.js", () => ({ vi.mock("../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(), getEntity: mocks.getEntity,
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }], listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
onEntitiesChanged: vi.fn(), onEntitiesChanged: vi.fn(),
})); }));
@@ -55,7 +57,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
vi.mock("../../sync/entities.js", () => ({ vi.mock("../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(), registerCoreSyncEntities: vi.fn(),
})); }));
vi.mock("../../sync/store.js", () => ({})); vi.mock("../../sync/store.js", () => ({
findStoredIds: mocks.findStoredIds,
}));
vi.mock("../../sync/wire.js", () => ({})); vi.mock("../../sync/wire.js", () => ({}));
vi.mock("../../sync/records.js", () => ({ vi.mock("../../sync/records.js", () => ({
listChanges: mocks.listChanges, listChanges: mocks.listChanges,
@@ -215,3 +219,22 @@ describe("host.links", () => {
}); });
}); });
}); });
describe("host.entities.idsOf", () => {
it("maps sync ids to the user's own row ids", async () => {
const entity = { type: "snippets", table: {} };
mocks.getEntity.mockReturnValue(entity);
mocks.findStoredIds.mockResolvedValue({ a: 3 });
expect(
await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]),
).toEqual({ a: 3 });
expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]);
});
it("answers nothing for an unknown or built type", async () => {
mocks.getEntity.mockReturnValue(undefined);
expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({});
mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() });
expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({});
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { info: vi.fn(), error: vi.fn() },
}));
const { renderNginxTemplate } = await import("../../tls/nginx-reload.js");
const TEMPLATE = [
"listen ${PORT};",
"listen ${SSL_PORT} ssl;",
"ssl_certificate ${SSL_CERT_PATH};",
" root /app/html;",
"pid /tmp/nginx/nginx.pid;",
].join("\n");
const values = {
port: "8080",
sslPort: "443",
certPath: "/data/ssl/termix.crt",
keyPath: "/data/ssl/termix.key",
};
describe("renderNginxTemplate", () => {
it("fills the ports and certificate", () => {
const out = renderNginxTemplate(TEMPLATE, values);
expect(out).toContain("listen 8080;");
expect(out).toContain("listen 443 ssl;");
expect(out).toContain("ssl_certificate /data/ssl/termix.crt;");
expect(out).toContain("root /app/html;");
});
it("uses the install folder in place of /app", () => {
const out = renderNginxTemplate(TEMPLATE, {
...values,
appDir: "/opt/termix/",
});
expect(out).toContain("root /opt/termix/html;");
expect(out).toContain("pid /tmp/nginx/nginx.pid;");
});
});
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hostOverride: null as number | null,
folderOverride: null as { folder: string; credentialId: number } | null,
ownerSecret: null as Record<string, unknown> | null,
usable: new Set<number>(),
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
}));
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) =>
state.usable.has(id) ? { id, username: `user-${id}` } : null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
}),
createCurrentRbacAccessRepository: () => ({
findActiveHostAccess: async () => null,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.hostOverride,
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
state.folderLookups.push({ folder, protocol });
return state.folderOverride;
},
}),
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
getSecretForUser: async () => state.ownerSecret,
snapshotForUser: async () => undefined,
}),
},
}));
const { resolveRecipientSharedHostAuthentication } =
await import("../../utils/shared-host-auth-resolver.js");
const host = (extra: Record<string, unknown> = {}) =>
({
id: 42,
userId: "owner",
folder: "Prod / Web",
authType: "credential",
credentialId: 99,
shareSshAuth: false,
...extra,
}) as never;
beforeEach(() => {
state.hostOverride = null;
state.folderOverride = null;
state.ownerSecret = null;
state.usable = new Set();
state.folderLookups = [];
});
describe("folder logins for shared hosts", () => {
it("uses the folder's login when the host has none of its own", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({
source: "personal-override",
credentialId: 7,
folder: "Prod",
});
expect(state.folderLookups).toEqual([
{ folder: "Prod / Web", protocol: "ssh" },
]);
});
it("lets the host's own login win over the folder's", async () => {
state.hostOverride = 5;
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(5).add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ credentialId: 5 });
expect(result).not.toHaveProperty("folder");
});
it("skips a folder login the user can no longer use", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.ownerSecret = { authType: "password", password: "x" };
const result = await resolveRecipientSharedHostAuthentication(
host({ shareSshAuth: true }),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ source: "owner-shared" });
});
it("asks for a login when nothing is set", async () => {
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toEqual({ source: "required" });
});
});
@@ -17,8 +17,14 @@ const state = vi.hoisted(() => ({
})); }));
vi.mock("../../hosts/usable-credential.js", () => ({ vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) => findUsableCredential: async (credentialId: number, userId: string) => {
state.credentials.get(credentialId) ?? null, const credential = state.credentials.get(credentialId);
if (!credential) return null;
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
return credential.userId === userId || sharedWith.includes(userId)
? credential
: null;
},
})); }));
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
@@ -29,8 +35,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null, findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
findHostOwnerId: async (hostId: number) => findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null, (state.hosts.get(hostId)?.userId as string) ?? null,
findCredentialByIdForUser: async (credentialId: number) => findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(credentialId) ?? null, state.credentials.get(credentialId)?.userId === userId
? state.credentials.get(credentialId)
: null,
}), }),
createCurrentSharedHostSecretsRepository: () => ({ createCurrentSharedHostSecretsRepository: () => ({
upsert: async (row: Record<string, unknown>) => { upsert: async (row: Record<string, unknown>) => {
@@ -260,6 +268,39 @@ describe("SharedHostSecretsManager", () => {
}); });
}); });
it("snapshots SSH auth from a credential shared with the host owner", async () => {
state.credentials.set(123, {
id: 123,
userId: "cred-owner",
sharedWith: ["owner"],
username: "cred-user",
authType: "password",
password: "cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
state.hosts.set(
42,
baseHost({
authType: "credential",
credentialId: 123,
shareSshAuth: true,
password: null,
}),
);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
username: "cred-user",
authType: "password",
password: "cred-pass",
});
});
it("snapshots a credential-backed protocol login from the credential", async () => { it("snapshots a credential-backed protocol login from the credential", async () => {
state.credentials.set(55, { state.credentials.set(55, {
id: 55, id: 55,
@@ -11,6 +11,7 @@ import {
getFriendlyKeyTypeName, getFriendlyKeyTypeName,
validateKeyPair, validateKeyPair,
isSecurityKeyFile, isSecurityKeyFile,
clearUnlockedKeyCache,
} from "../../utils/ssh-key-utils.js"; } from "../../utils/ssh-key-utils.js";
const { parsePrivateKey } = sshpk; const { parsePrivateKey } = sshpk;
@@ -226,6 +227,32 @@ describe("PKCS#8 runtime key normalization", () => {
}); });
}); });
describe("encrypted OpenSSH keys", () => {
const key = sshpk.generatePrivateKey("ed25519");
const encrypted = key.toString("openssh", { passphrase: "pw" }).trim();
it("unlocks once and hands ssh2 a key that needs no passphrase", () => {
clearUnlockedKeyCache();
const first = preparePrivateKeyForSSH2(encrypted, "pw");
const parsed = ssh2.utils.parseKey(first);
expect(parsed).not.toBeInstanceOf(Error);
expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe(
key.fingerprint().toString(),
);
expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe(
first.toString(),
);
});
it("leaves the key locked for a wrong or missing passphrase", () => {
clearUnlockedKeyCache();
expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe(
encrypted,
);
expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted);
});
});
describe("security key files", () => { describe("security key files", () => {
const str = (v: string | Buffer) => { const str = (v: string | Buffer) => {
const b = Buffer.from(v); const b = Buffer.from(v);
+45 -15
View File
@@ -3,9 +3,40 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "path"; import path from "path";
import { authLogger } from "../utils/logger.js"; import { authLogger } from "../utils/logger.js";
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template"; // The Docker image's paths. An install outside Docker (a Proxmox LXC script,
const NGINX_CONF = "/tmp/nginx/nginx.conf"; // a source checkout) points these at its own nginx instead.
const NGINX_PID = "/tmp/nginx/nginx.pid"; const nginxTemplate = () =>
process.env.TERMIX_NGINX_TEMPLATE || "/app/nginx/nginx-https.conf.template";
const nginxConf = () =>
process.env.TERMIX_NGINX_CONF || "/tmp/nginx/nginx.conf";
const nginxPid = () => process.env.TERMIX_NGINX_PID || "/tmp/nginx/nginx.pid";
/** The template with Termix's own folder in place of the image's /app. */
export function renderNginxTemplate(
template: string,
values: {
port: string;
sslPort: string;
certPath: string;
keyPath: string;
appDir?: string;
},
): string {
let rendered = template
.split("${PORT}")
.join(values.port)
.split("${SSL_PORT}")
.join(values.sslPort)
.split("${SSL_CERT_PATH}")
.join(values.certPath)
.split("${SSL_KEY_PATH}")
.join(values.keyPath);
const appDir = values.appDir?.replace(/\/+$/, "");
if (appDir && appDir !== "/app") {
rendered = rendered.replace(/(^|[\s;])\/app\//gm, `$1${appDir}/`);
}
return rendered;
}
const DATA_DIR = process.env.DATA_DIR || "./db/data"; const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl"); const SSL_DIR = path.join(DATA_DIR, "ssl");
@@ -51,11 +82,13 @@ export function persistSSLEnv(
* restart. No-ops outside the Docker image (template/binary won't exist). * restart. No-ops outside the Docker image (template/binary won't exist).
*/ */
export function reloadNginxWithSSL(): { applied: boolean; message: string } { export function reloadNginxWithSSL(): { applied: boolean; message: string } {
if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) { const NGINX_TEMPLATE = nginxTemplate();
const NGINX_CONF = nginxConf();
if (!existsSync(NGINX_TEMPLATE) || !existsSync(nginxPid())) {
return { return {
applied: false, applied: false,
message: message:
"nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.", "Termix could not find the nginx it runs with, so HTTPS is not on yet. In Docker, restart Termix with ENABLE_SSL=true. On another install, set TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF and TERMIX_NGINX_PID (and TERMIX_APP_DIR if it is not /app), or add a listen 443 ssl block with this certificate to your nginx config.",
}; };
} }
@@ -66,16 +99,13 @@ export function reloadNginxWithSSL(): { applied: boolean; message: string } {
const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key"); const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key");
try { try {
const template = readFileSync(NGINX_TEMPLATE, "utf8"); const rendered = renderNginxTemplate(readFileSync(NGINX_TEMPLATE, "utf8"), {
const rendered = template port,
.split("${PORT}") sslPort,
.join(port) certPath,
.split("${SSL_PORT}") keyPath,
.join(sslPort) appDir: process.env.TERMIX_APP_DIR,
.split("${SSL_CERT_PATH}") });
.join(certPath)
.split("${SSL_KEY_PATH}")
.join(keyPath);
writeFileSync(NGINX_CONF, rendered); writeFileSync(NGINX_CONF, rendered);
@@ -1,10 +1,10 @@
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js"; import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js"; import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
import { import {
createCurrentCredentialRepository,
createCurrentSharedHostAuthOverrideRepository, createCurrentSharedHostAuthOverrideRepository,
createCurrentUserRepository, createCurrentUserRepository,
} from "../database/repositories/factory.js"; } from "../database/repositories/factory.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import { logAudit } from "./audit-logger.js"; import { logAudit } from "./audit-logger.js";
import { PermissionManager } from "./permission-manager.js"; import { PermissionManager } from "./permission-manager.js";
@@ -60,11 +60,7 @@ export class SharedHostAuthOverrideService {
await this.requireSharedHostAccess(hostId, userId); await this.requireSharedHostAccess(hostId, userId);
if (credentialId !== null) { if (credentialId !== null) {
const credential = const credential = await findUsableCredential(credentialId, userId);
await createCurrentCredentialRepository().findByIdForUser(
userId,
credentialId,
);
if (!credential) { if (!credential) {
throw new SharedHostAuthOverrideServiceError( throw new SharedHostAuthOverrideServiceError(
"Credential not found", "Credential not found",
+43 -6
View File
@@ -4,6 +4,7 @@ import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentSharedHostAuthOverrideRepository, createCurrentSharedHostAuthOverrideRepository,
} from "../database/repositories/factory.js"; } from "../database/repositories/factory.js";
import type { import type {
@@ -12,6 +13,7 @@ import type {
} from "../database/repositories/host-resolution-repository.js"; } from "../database/repositories/host-resolution-repository.js";
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js"; import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import { import {
SharedHostSecretsManager, SharedHostSecretsManager,
type SharedSecretData, type SharedSecretData,
@@ -22,6 +24,8 @@ export type RecipientSharedHostAuthResolution =
source: "personal-override"; source: "personal-override";
credentialId: number; credentialId: number;
credential: HostResolutionCredentialRecord; credential: HostResolutionCredentialRecord;
/** Set when the login comes from the folder, not the host itself. */
folder?: string;
} }
| { | {
source: "owner-shared"; source: "owner-shared";
@@ -53,8 +57,8 @@ function isOwnerAuthShared(
/** /**
* Applies the shared-host authentication precedence independently from any * Applies the shared-host authentication precedence independently from any
* transport: recipient override, shared owner auth, secretless auth, then * transport: recipient override on the host, then on its folder, shared
* "required". * owner auth, secretless auth, then "required".
*/ */
export async function resolveRecipientSharedHostAuthentication( export async function resolveRecipientSharedHostAuthentication(
host: HostResolutionHostRecord, host: HostResolutionHostRecord,
@@ -80,10 +84,7 @@ export async function resolveRecipientSharedHostAuthentication(
} }
if (overrideCredentialId) { if (overrideCredentialId) {
const credential = await repository.findCredentialByIdForUser( const credential = await findUsableCredential(overrideCredentialId, userId);
overrideCredentialId,
userId,
);
if (credential) { if (credential) {
return { return {
source: "personal-override", source: "personal-override",
@@ -93,6 +94,31 @@ export async function resolveRecipientSharedHostAuthentication(
} }
} }
try {
const folderOverride =
await createCurrentSharedFolderAuthOverrideRepository().findClosest(
userId,
host.folder,
protocol,
);
if (folderOverride) {
const credential = await findUsableCredential(
folderOverride.credentialId,
userId,
);
if (credential) {
return {
source: "personal-override",
credentialId: folderOverride.credentialId,
credential,
folder: folderOverride.folder,
};
}
}
} catch {
// Same as a host override: an unreadable one counts as none.
}
if (isOwnerAuthShared(host, protocol)) { if (isOwnerAuthShared(host, protocol)) {
if (protocol === "ssh" && host.authType === "agent") { if (protocol === "ssh" && host.authType === "agent") {
return { return {
@@ -145,6 +171,17 @@ export async function resolveRecipientSharedHostAuthentication(
} }
} }
if (protocol === "ssh" && host.credentialId) {
const credential = await findUsableCredential(host.credentialId, userId);
if (credential) {
return {
source: "personal-override",
credentialId: host.credentialId,
credential,
};
}
}
return requiresPersonalHostAuthentication(host, protocol) return requiresPersonalHostAuthentication(host, protocol)
? { source: "required" } ? { source: "required" }
: { source: "secretless" }; : { source: "secretless" };
@@ -381,12 +381,11 @@ class SharedHostSecretsManager {
host: HostResolutionHostRecord, host: HostResolutionHostRecord,
ownerId: string, ownerId: string,
): Promise<ProtocolSnapshot[]> { ): Promise<ProtocolSnapshot[]> {
const repository = createCurrentHostResolutionRepository();
const snapshots: ProtocolSnapshot[] = []; const snapshots: ProtocolSnapshot[] = [];
if (sshEnabled(host) && host.shareSshAuth) { if (sshEnabled(host) && host.shareSshAuth) {
if (host.credentialId) { if (host.credentialId) {
const credential = await repository.findCredentialByIdForUser( const credential = await findUsableCredential(
host.credentialId, host.credentialId,
ownerId, ownerId,
); );
+49 -2
View File
@@ -1,4 +1,4 @@
import { createPrivateKey } from "node:crypto"; import { createHash, createPrivateKey } from "node:crypto";
import sshpk from "sshpk"; import sshpk from "sshpk";
import { getErrorMessage } from "./error-message.js"; import { getErrorMessage } from "./error-message.js";
import ssh2Pkg from "ssh2"; import ssh2Pkg from "ssh2";
@@ -264,12 +264,59 @@ export function isSecurityKeyFile(privateKeyData: string): boolean {
} }
} }
// Unlocking an OpenSSH key runs bcrypt on the main thread (about a second per
// try), and every connect used to do it 3 or more times. Keep the unlocked copy
// in memory for a short while so a burst of connects pays for it once.
const UNLOCKED_KEY_TTL_MS = 15 * 60 * 1000;
const UNLOCKED_KEY_MAX = 64;
const unlockedKeys = new Map<string, { text: string; expires: number }>();
function unlockOpenSSHKey(cleanKey: string, passphrase?: string): string {
if (
!passphrase ||
!cleanKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")
)
return cleanKey;
const id = createHash("sha256")
.update(cleanKey)
.update("\n")
.update(passphrase)
.digest("hex");
const now = Date.now();
const hit = unlockedKeys.get(id);
if (hit && hit.expires > now) return hit.text;
unlockedKeys.delete(id);
let text: string;
try {
text = parsePrivateKey(cleanKey, "openssh", { passphrase }).toString(
"openssh",
);
} catch {
// Wrong passphrase or a cipher sshpk lacks; the normal path reports it.
return cleanKey;
}
if (ssh2Utils.parseKey(text) instanceof Error) return cleanKey;
if (unlockedKeys.size >= UNLOCKED_KEY_MAX) {
const oldest = unlockedKeys.keys().next().value;
if (oldest !== undefined) unlockedKeys.delete(oldest);
}
unlockedKeys.set(id, { text, expires: now + UNLOCKED_KEY_TTL_MS });
return text;
}
export function clearUnlockedKeyCache(): void {
unlockedKeys.clear();
}
export function preparePrivateKeyForSSH2( export function preparePrivateKeyForSSH2(
privateKeyData: string, privateKeyData: string,
passphrase?: string, passphrase?: string,
): Buffer { ): Buffer {
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR); if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
let cleanKey = normalizePrivateKeyText(privateKeyData); let cleanKey = unlockOpenSSHKey(
normalizePrivateKeyText(privateKeyData),
passphrase,
);
if ( if (
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) && /^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
+7
View File
@@ -87,6 +87,8 @@ export interface HostSidebarDisplayPreferences extends HostRowFields {
openOnDoubleClick: boolean; openOnDoubleClick: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */ /** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths: boolean; showFolderPaths: boolean;
/** When false, rows keep one background instead of alternating. */
stripedRows: boolean;
hostClickBehavior: HostClickBehavior; hostClickBehavior: HostClickBehavior;
/** When true, the sidebar closes after a host is opened from it. */ /** When true, the sidebar closes after a host is opened from it. */
closeOnOpen: boolean; closeOnOpen: boolean;
@@ -164,6 +166,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent", statusColorScheme: "accent",
openOnDoubleClick: false, openOnDoubleClick: false,
showFolderPaths: true, showFolderPaths: true,
stripedRows: true,
hostClickBehavior: "newTab", hostClickBehavior: "newTab",
closeOnOpen: false, closeOnOpen: false,
barActions: {}, barActions: {},
@@ -272,6 +275,10 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean" typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths ? displayObj.showFolderPaths
: defaults.display.showFolderPaths, : defaults.display.showFolderPaths,
stripedRows:
typeof displayObj.stripedRows === "boolean"
? displayObj.stripedRows
: defaults.display.stripedRows,
hostClickBehavior: HOST_CLICK_BEHAVIORS.includes( hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
displayObj.hostClickBehavior as HostClickBehavior, displayObj.hostClickBehavior as HostClickBehavior,
) )
+15 -2
View File
@@ -643,11 +643,24 @@ export interface PluginUploadPreview {
features?: string[]; features?: string[];
capabilities: string[]; capabilities: string[];
replaces: string | null; replaces: string | null;
official?: boolean;
} }
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> { export async function uploadPlugin(
file: Blob,
signature?: string,
): Promise<PluginUploadPreview> {
const response = await rbacApi.post("/plugins/upload", file, { const response = await rbacApi.post("/plugins/upload", file, {
headers: { "Content-Type": "application/octet-stream" }, headers: {
"Content-Type": "application/octet-stream",
...(signature
? {
"X-Termix-Plugin-Signature": btoa(
String.fromCharCode(...new TextEncoder().encode(signature)),
),
}
: {}),
},
}); });
return response.data; return response.data;
} }
+55 -2
View File
@@ -238,9 +238,14 @@ export async function getFolderAccess(
} }
} }
export async function revokeFolderAccess(ruleId: number): Promise<void> { export async function revokeFolderAccess(
ruleId: number,
options: { fromHosts?: boolean } = {},
): Promise<void> {
try { try {
await rbacApi.delete(`/rbac/folder/access/${ruleId}`); await rbacApi.delete(`/rbac/folder/access/${ruleId}`, {
params: options.fromHosts ? { fromHosts: "true" } : undefined,
});
} catch (error) { } catch (error) {
throw handleApiError(error, "revoke folder access"); throw handleApiError(error, "revoke folder access");
} }
@@ -383,6 +388,54 @@ export async function getHostAuthOverride(
} }
} }
/**
* The user's own login for hosts shared with them in a folder. On a desktop
* linked to a server, pass a shared host from the folder so the call goes to
* that server.
*/
export async function getFolderAuthOverride(
folder: string,
protocol: AuthOverrideProtocol,
sharedCopySyncId?: string | null,
): Promise<{
folder: string;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const target = await getSharedCopyTarget(0, sharedCopySyncId);
const response = await target.api.get(`/rbac/folder-auth/${protocol}`, {
params: { folder },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch folder authentication override");
}
}
export async function setFolderAuthOverride(
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number | null,
sharedCopySyncId?: string | null,
): Promise<{
success: boolean;
folder: string;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const target = await getSharedCopyTarget(0, sharedCopySyncId);
const response = await target.api.put(`/rbac/folder-auth/${protocol}`, {
folder,
credentialId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update folder authentication override");
}
}
export async function setHostAuthOverride( export async function setHostAuthOverride(
hostId: number, hostId: number,
protocol: AuthOverrideProtocol, protocol: AuthOverrideProtocol,
+52 -12
View File
@@ -14,11 +14,32 @@ export interface DataColumn<T> {
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */ /** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
width: string; width: string;
cell: (item: T) => React.ReactNode; cell: (item: T) => React.ReactNode;
/** Dropped below md, for columns that are nice rather than necessary. */ /** Hidden when the table itself is narrower than md (48rem) / lg (64rem). */
hideBelow?: "md" | "lg"; hideBelow?: "md" | "lg";
align?: "start" | "end"; align?: "start" | "end";
} }
/** Tracks for the columns visible at each width, so hidden ones take no space. */
function listColumnTemplates<T>(columns: DataColumn<T>[]): {
base: string;
md: string;
lg: string;
} {
return {
base:
columns
.filter((column) => column.hideBelow === undefined)
.map((column) => column.width)
.join(" ") || "none",
md:
columns
.filter((column) => column.hideBelow !== "lg")
.map((column) => column.width)
.join(" ") || "none",
lg: columns.map((column) => column.width).join(" ") || "none",
};
}
export interface GridColumns { export interface GridColumns {
base?: number; base?: number;
md?: number; md?: number;
@@ -52,6 +73,9 @@ const XL_COLS: Record<number, string> = {
4: "xl:grid-cols-4", 4: "xl:grid-cols-4",
}; };
const LIST_GRID_CLASS =
"grid grid-cols-[var(--data-view-cols)] @min-[48rem]:grid-cols-[var(--data-view-cols-md)] @min-[64rem]:grid-cols-[var(--data-view-cols-lg)] gap-x-2 px-3";
function gridClass(columns: GridColumns): string { function gridClass(columns: GridColumns): string {
return cn( return cn(
COLS[columns.base ?? 1], COLS[columns.base ?? 1],
@@ -61,6 +85,12 @@ function gridClass(columns: GridColumns): string {
); );
} }
function columnVisibility(hideBelow?: "md" | "lg"): string {
if (hideBelow === "md") return "hidden @min-[48rem]:block";
if (hideBelow === "lg") return "hidden @min-[64rem]:block";
return "";
}
/** One collection as a card grid or a dense row table. */ /** One collection as a card grid or a dense row table. */
export function DataView<T>({ export function DataView<T>({
items, items,
@@ -120,14 +150,26 @@ export function DataView<T>({
); );
} }
const template = listColumns.map((c) => c.width).join(" "); const templates = listColumnTemplates(listColumns);
const rowHeight = density === "compact" ? "h-7" : "h-9"; const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return ( return (
<div className={cn("border border-border bg-card", className)} role="table"> <div
className={cn("@container border border-border bg-card", className)}
style={
{
"--data-view-cols": templates.base,
"--data-view-cols-md": templates.md,
"--data-view-cols-lg": templates.lg,
} as React.CSSProperties
}
role="table"
>
<div <div
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground" className={cn(
style={{ gridTemplateColumns: template }} LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}
> >
{listColumns.map((column) => ( {listColumns.map((column) => (
<span <span
@@ -135,8 +177,7 @@ export function DataView<T>({
className={cn( className={cn(
"truncate py-2", "truncate py-2",
column.align === "end" && "text-right", column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block", columnVisibility(column.hideBelow),
column.hideBelow === "lg" && "hidden lg:block",
)} )}
> >
{column.header} {column.header}
@@ -152,12 +193,12 @@ export function DataView<T>({
key={getKey(item)} key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined} onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn( className={cn(
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0", LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
rowHeight, rowHeight,
onRowClick && "cursor-pointer", onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40", active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)} )}
style={{ gridTemplateColumns: template }}
> >
{listColumns.map((column) => ( {listColumns.map((column) => (
<div <div
@@ -165,8 +206,7 @@ export function DataView<T>({
className={cn( className={cn(
"min-w-0 truncate", "min-w-0 truncate",
column.align === "end" && "text-right", column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block", columnVisibility(column.hideBelow),
column.hideBelow === "lg" && "hidden lg:block",
)} )}
> >
{column.cell(item)} {column.cell(item)}
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Close the sidebar after you open a host from it", "closeOnOpenDesc": "Close the sidebar after you open a host from it",
"showFolderPaths": "Show folder paths", "showFolderPaths": "Show folder paths",
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.", "showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
"stripedRows": "Alternating row colors",
"stripedRowsDesc": "Shade every other row in the host list",
"rowDetailsTitle": "Row details", "rowDetailsTitle": "Row details",
"rowDetailsDesc": "Choose what shows on each host.", "rowDetailsDesc": "Choose what shows on each host.",
"showAddress": "Address", "showAddress": "Address",
@@ -775,6 +777,8 @@
"sharedBadge": "Shared", "sharedBadge": "Shared",
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)", "sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.", "viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
"folderRuleRemoveFromHosts": "Also remove this access from every host in the folder and its subfolders",
"sharedCopyBanner": "This host is shared with you by {{owner}}. The desktop keeps a read-only copy, so change it on the server and it syncs here.",
"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.", "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", "ownerOnlyControl": "Only the host owner can change this",
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.", "ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het", "closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het",
"showFolderPaths": "Wys gidsroetes", "showFolderPaths": "Wys gidsroetes",
"showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.", "showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.",
"stripedRows": "Wisselende rykleure",
"stripedRowsDesc": "Skadu elke tweede ry in die gasheerlys",
"rowDetailsTitle": "Rybesonderhede", "rowDetailsTitle": "Rybesonderhede",
"rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.", "rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.",
"showAddress": "Adres", "showAddress": "Adres",
@@ -775,6 +777,8 @@
"sharedBadge": "Gedeel", "sharedBadge": "Gedeel",
"sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)", "sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)",
"viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.", "viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.",
"folderRuleRemoveFromHosts": "Verwyder ook hierdie toegang van elke gasheer in die gids en sy subgidse",
"sharedCopyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}}. Die werkskoppie hou 'n lees-slegs kopie, so verander dit op die bediener en dit sinkroniseer hier.",
"sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.", "sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.",
"ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander", "ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander",
"ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.", "ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.",
+4
View File
@@ -614,6 +614,8 @@
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه", "closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
"showFolderPaths": "إظهار مسارات المجلدات", "showFolderPaths": "إظهار مسارات المجلدات",
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.", "showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
"stripedRows": "ألوان صفوف متناوبة",
"stripedRowsDesc": "تظليل كل صف ثانٍ في قائمة المضيفين",
"rowDetailsTitle": "تفاصيل الصف", "rowDetailsTitle": "تفاصيل الصف",
"rowDetailsDesc": "اختر ما يظهر على كل جهاز.", "rowDetailsDesc": "اختر ما يظهر على كل جهاز.",
"showAddress": "العنوان", "showAddress": "العنوان",
@@ -823,6 +825,8 @@
"sharedBadge": "مشترك", "sharedBadge": "مشترك",
"sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})", "sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})",
"viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.", "viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.",
"folderRuleRemoveFromHosts": "إزالة هذا الوصول أيضاً من كل مضيف في المجلد والمجلدات الفرعية الخاصة به",
"sharedCopyBanner": "تم مشاركة هذا المضيف معك بواسطة {{owner}}. يحتفظ سطح المكتب بنسخة للقراءة فقط، لذا قم بتعديله على الخادم وسيتم مزامنته هنا.",
"sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.", "sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.",
"ownerOnlyControl": "يمكن للمالك فقط تغيير هذا", "ownerOnlyControl": "يمكن للمالك فقط تغيير هذا",
"ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.", "ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея", "closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
"showFolderPaths": "Показване на пътищата до папките", "showFolderPaths": "Показване на пътищата до папките",
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.", "showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
"stripedRows": "Чередуване на цветовете на редовете",
"stripedRowsDesc": "Оцветяване на всеки втори ред в списъка с хостове",
"rowDetailsTitle": "Детайли на реда", "rowDetailsTitle": "Детайли на реда",
"rowDetailsDesc": "Изберете какво се показва за всеки хост.", "rowDetailsDesc": "Изберете какво се показва за всеки хост.",
"showAddress": "Адрес", "showAddress": "Адрес",
@@ -775,6 +777,8 @@
"sharedBadge": "Споделен", "sharedBadge": "Споделен",
"sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})", "sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})",
"viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.", "viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.",
"folderRuleRemoveFromHosts": "Премахване на достъпа и от всички хостове в папката и подпапките ѝ",
"sharedCopyBanner": "Този хост е споделен с вас от {{owner}}. Работният плот запазва само за четене копия, така че променете го на сървъра и той ще се синхронизира тук.",
"sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.", "sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.",
"ownerOnlyControl": "Само собственикът на хоста може да промени това", "ownerOnlyControl": "Само собственикът на хоста може да промени това",
"ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.", "ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন", "closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
"showFolderPaths": "ফোল্ডার পথ দেখান", "showFolderPaths": "ফোল্ডার পথ দেখান",
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।", "showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
"stripedRows": "বিকল্প সারির রঙ",
"stripedRowsDesc": "হোস্ট তালিকায় প্রতিটি বিকল্প সারি রঙিন করুন",
"rowDetailsTitle": "সারির বিবরণ", "rowDetailsTitle": "সারির বিবরণ",
"rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।", "rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।",
"showAddress": "ঠিকানা", "showAddress": "ঠিকানা",
@@ -775,6 +777,8 @@
"sharedBadge": "শেয়ার করা", "sharedBadge": "শেয়ার করা",
"sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)", "sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)",
"viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।", "viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।",
"folderRuleRemoveFromHosts": "এই ফোল্ডার এবং এর সাবফোল্ডারে থাকা প্রতিটি হোস্ট থেকেও এই অ্যাক্সেস সরিয়ে দিন",
"sharedCopyBanner": "{{owner}} আপনাকে এই হোস্টটি শেয়ার করেছেন। ডেস্কটপটি একটি রিড-অনলি কপি রাখে, তাই সার্ভারে এটি পরিবর্তন করলে এটি এখানে সিঙ্ক হবে।",
"sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।", "sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।",
"ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন", "ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন",
"ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।", "ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella", "closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella",
"showFolderPaths": "Mostra les rutes de les carpetes", "showFolderPaths": "Mostra les rutes de les carpetes",
"showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.", "showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.",
"stripedRows": "Colors alternats de les files",
"stripedRowsDesc": "Aplicar un color alternat a cada file de la llista d'amfitrions",
"rowDetailsTitle": "Detalls de la fila", "rowDetailsTitle": "Detalls de la fila",
"rowDetailsDesc": "Tria què es mostra en cada amfitrió.", "rowDetailsDesc": "Tria què es mostra en cada amfitrió.",
"showAddress": "Adreça", "showAddress": "Adreça",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartit", "sharedBadge": "Compartit",
"sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})", "sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})",
"viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.", "viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.",
"folderRuleRemoveFromHosts": "També elimina aquest accés de tots els amfitrions de la carpeta i les seves subcarpetes",
"sharedCopyBanner": "Aquest amfitrió ha estat compartit amb tu per {{owner}}. L'escriptori manté una còpia de només lectura, de manera que si el modifiques al servidor, els canvis es sincronitzaran aquí.",
"sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.", "sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.",
"ownerOnlyControl": "Només el propietari de l'host pot canviar això", "ownerOnlyControl": "Només el propietari de l'host pot canviar això",
"ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.", "ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Zavřít postranní panel po otevření hostitele z něj", "closeOnOpenDesc": "Zavřít postranní panel po otevření hostitele z něj",
"showFolderPaths": "Zobrazit cesty složek", "showFolderPaths": "Zobrazit cesty složek",
"showFolderPathsDesc": "Zobrazit cestu nadřazené složky nad jmény vnořených podsložek. Podsložky zůstanou v obou případech odsazené.", "showFolderPathsDesc": "Zobrazit cestu nadřazené složky nad jmény vnořených podsložek. Podsložky zůstanou v obou případech odsazené.",
"stripedRows": "Střídavé barvy řádků",
"stripedRowsDesc": "Ztmavit každý druhý řádek v seznamu hostitelů",
"rowDetailsTitle": "Detaily řádku", "rowDetailsTitle": "Detaily řádku",
"rowDetailsDesc": "Vyberte, co se zobrazí u každého hosta.", "rowDetailsDesc": "Vyberte, co se zobrazí u každého hosta.",
"showAddress": "Adresa", "showAddress": "Adresa",
@@ -799,6 +801,8 @@
"sharedBadge": "Sdílený", "sharedBadge": "Sdílený",
"sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} (přístup {{level}})", "sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} (přístup {{level}})",
"viewOnlyBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pouze pro zobrazení. Konfigurace je pouze pro čtení.", "viewOnlyBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pouze pro zobrazení. Konfigurace je pouze pro čtení.",
"folderRuleRemoveFromHosts": "Tento přístup odebrat také ze všech hostitelů v této složce a jejích podsložkách",
"sharedCopyBanner": "Tento hostitel vám sdílí {{owner}}. Desktop si uchovává kopii pouze pro čtení, takže změny proveďte na serveru a synchronizují se zde.",
"sharedEditBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pro úpravy. Změny se projeví na skutečném hostiteli; ověřovací údaje může měnit pouze vlastník.", "sharedEditBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pro úpravy. Změny se projeví na skutečném hostiteli; ověřovací údaje může měnit pouze vlastník.",
"ownerOnlyControl": "Toto může změnit pouze vlastník hostitele", "ownerOnlyControl": "Toto může změnit pouze vlastník hostitele",
"ownerAuthPrivate": "SSH ověřování vlastníka hostitele je soukromé. V nabídce hostitele použijte „Nastavit vlastní SSH ověřování“ pro výběr vlastních přihlašovacích údajů.", "ownerAuthPrivate": "SSH ověřování vlastníka hostitele je soukromé. V nabídce hostitele použijte „Nastavit vlastní SSH ověřování“ pro výběr vlastních přihlašovacích údajů.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Luk sidebjælken, når du åbner en vært fra den", "closeOnOpenDesc": "Luk sidebjælken, når du åbner en vært fra den",
"showFolderPaths": "Vis mappesstier", "showFolderPaths": "Vis mappesstier",
"showFolderPathsDesc": "Vis overordnede mappesstier over navne på indlejrede undermapper. Undermapper forbliver indrykket uanset hvad.", "showFolderPathsDesc": "Vis overordnede mappesstier over navne på indlejrede undermapper. Undermapper forbliver indrykket uanset hvad.",
"stripedRows": "Skiftende rækkefarver",
"stripedRowsDesc": "Skyg hver anden række i værtslisten",
"rowDetailsTitle": "Radedetaljer", "rowDetailsTitle": "Radedetaljer",
"rowDetailsDesc": "Vælg, hvad der vises for hver vært.", "rowDetailsDesc": "Vælg, hvad der vises for hver vært.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Delt", "sharedBadge": "Delt",
"sharedBadgeTooltip": "Delt af {{owner}} ({{level}}-adgang)", "sharedBadgeTooltip": "Delt af {{owner}} ({{level}}-adgang)",
"viewOnlyBanner": "Denne vært er delt med dig af {{owner}} med visningsadgang. Konfigurationen er skrivebeskyttet.", "viewOnlyBanner": "Denne vært er delt med dig af {{owner}} med visningsadgang. Konfigurationen er skrivebeskyttet.",
"folderRuleRemoveFromHosts": "Fjern også denne adgang fra alle værter i mappen og dens undermapper",
"sharedCopyBanner": "Denne vært er delt med dig af {{owner}}. Skrivebordet gemmer en skrivebeskyttet kopi, så ændr den på serveren, og den synkroniseres her.",
"sharedEditBanner": "Denne vært er delt med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den faktiske vært; autentificeringsreferencer kan kun ændres af ejeren.", "sharedEditBanner": "Denne vært er delt med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den faktiske vært; autentificeringsreferencer kan kun ændres af ejeren.",
"ownerOnlyControl": "Kun værtejeren kan ændre dette", "ownerOnlyControl": "Kun værtejeren kan ændre dette",
"ownerAuthPrivate": "Værtejerens SSH-autentificering er privat. Brug “Indstil personlig SSH-autentificering” i værtsmenuen for at vælge dine egne loginoplysninger.", "ownerAuthPrivate": "Værtejerens SSH-autentificering er privat. Brug “Indstil personlig SSH-autentificering” i værtsmenuen for at vælge dine egne loginoplysninger.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Schließe die Seitenleiste, nachdem du einen Host daraus geöffnet hast", "closeOnOpenDesc": "Schließe die Seitenleiste, nachdem du einen Host daraus geöffnet hast",
"showFolderPaths": "Verzeichnispfade anzeigen", "showFolderPaths": "Verzeichnispfade anzeigen",
"showFolderPathsDesc": "Zeigt den Pfad des übergeordneten Verzeichnisses über verschachtelten Unterverzeichnisnamen an. Unterverzeichnisse bleiben in jedem Fall eingerückt.", "showFolderPathsDesc": "Zeigt den Pfad des übergeordneten Verzeichnisses über verschachtelten Unterverzeichnisnamen an. Unterverzeichnisse bleiben in jedem Fall eingerückt.",
"stripedRows": "Abwechselnde Zeilenfarben",
"stripedRowsDesc": "Jede zweite Zeile in der Host-Liste einfärben",
"rowDetailsTitle": "Zeilen-Details", "rowDetailsTitle": "Zeilen-Details",
"rowDetailsDesc": "Wählen Sie aus, was bei jedem Host angezeigt wird.", "rowDetailsDesc": "Wählen Sie aus, was bei jedem Host angezeigt wird.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Geteilt", "sharedBadge": "Geteilt",
"sharedBadgeTooltip": "Geteilt von {{owner}} ({{level}}-Zugriff)", "sharedBadgeTooltip": "Geteilt von {{owner}} ({{level}}-Zugriff)",
"viewOnlyBanner": "Dieser Host wurde von {{owner}} mit Leserechten mit Ihnen geteilt. Die Konfiguration ist schreibgeschützt.", "viewOnlyBanner": "Dieser Host wurde von {{owner}} mit Leserechten mit Ihnen geteilt. Die Konfiguration ist schreibgeschützt.",
"folderRuleRemoveFromHosts": "Diesen Zugriff auch von allen Hosts im Ordner und seinen Unterordnern entfernen",
"sharedCopyBanner": "Dieser Host wurde von {{owner}} mit Ihnen geteilt. Der Desktop behält eine schreibgeschützte Kopie, sodass Änderungen am Server hier synchronisiert werden.",
"sharedEditBanner": "Dieser Host wurde von {{owner}} mit Bearbeitungsberechtigungen mit Ihnen geteilt. Änderungen gelten für den echten Host; Authentifizierungsreferenzen können nur vom Besitzer geändert werden.", "sharedEditBanner": "Dieser Host wurde von {{owner}} mit Bearbeitungsberechtigungen mit Ihnen geteilt. Änderungen gelten für den echten Host; Authentifizierungsreferenzen können nur vom Besitzer geändert werden.",
"ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern", "ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern",
"ownerAuthPrivate": "Die SSH-Authentifizierung des Host-Besitzers ist privat. Wählen Sie im Host-Menü „Persönliche SSH-Authentifizierung festlegen“, um Ihre eigenen Anmeldeinformationen auszuwählen.", "ownerAuthPrivate": "Die SSH-Authentifizierung des Host-Besitzers ist privat. Wählen Sie im Host-Menü „Persönliche SSH-Authentifizierung festlegen“, um Ihre eigenen Anmeldeinformationen auszuwählen.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν", "closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν",
"showFolderPaths": "Εμφάνιση διαδρομών φακέλων", "showFolderPaths": "Εμφάνιση διαδρομών φακέλων",
"showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.", "showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.",
"stripedRows": "Εναλλασσόμενα χρώματα γραμμών",
"stripedRowsDesc": "Σκίαση κάθε δεύτερης γραμμής στη λίστα των hosts",
"rowDetailsTitle": "Λεπτομέρειες γραμμής", "rowDetailsTitle": "Λεπτομέρειες γραμμής",
"rowDetailsDesc": "Επιλέξτε τι εμφανίζεται σε κάθε host.", "rowDetailsDesc": "Επιλέξτε τι εμφανίζεται σε κάθε host.",
"showAddress": "Διεύθυνση", "showAddress": "Διεύθυνση",
@@ -775,6 +777,8 @@
"sharedBadge": "Κοινόχρηστο", "sharedBadge": "Κοινόχρηστο",
"sharedBadgeTooltip": "Κοινόχρηστο από τον {{owner}} (πρόσβαση {{level}})", "sharedBadgeTooltip": "Κοινόχρηστο από τον {{owner}} (πρόσβαση {{level}})",
"viewOnlyBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.", "viewOnlyBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.",
"folderRuleRemoveFromHosts": "Αφαίρεση επίσης αυτής της πρόσβασης από κάθε host στον φάκελο και τους υποφακέλους του",
"sharedCopyBanner": "Αυτός ο host μοιράζεται μαζί σας από τον {{owner}}. Το desktop διατηρεί ένα αντίγραφο μόνο για ανάγνωση, οπότε κάντε αλλαγές στον server και θα συγχρονιστούν εδώ.",
"sharedEditBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα επεξεργασίας. Οι αλλαγές εφαρμόζονται στον πραγματικό υπολογιστή· οι αναφορές πιστοποίησης μπορούν να αλλάξουν μόνο από τον ιδιοκτήτη.", "sharedEditBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα επεξεργασίας. Οι αλλαγές εφαρμόζονται στον πραγματικό υπολογιστή· οι αναφορές πιστοποίησης μπορούν να αλλάξουν μόνο από τον ιδιοκτήτη.",
"ownerOnlyControl": "Μόνο ο ιδιοκτήτης του υπολογιστή μπορεί να αλλάξει αυτό", "ownerOnlyControl": "Μόνο ο ιδιοκτήτης του υπολογιστή μπορεί να αλλάξει αυτό",
"ownerAuthPrivate": "Η SSH πιστοποίηση του ιδιοκτήτη του υπολογιστή είναι ιδιωτική. Χρησιμοποιήστε την επιλογή «Ορισμός προσωπικής SSH πιστοποίησης» από το μενού του υπολογιστή για να επιλέξετε το δικό σας διαπιστευτήριο.", "ownerAuthPrivate": "Η SSH πιστοποίηση του ιδιοκτήτη του υπολογιστή είναι ιδιωτική. Χρησιμοποιήστε την επιλογή «Ορισμός προσωπικής SSH πιστοποίησης» από το μενού του υπολογιστή για να επιλέξετε το δικό σας διαπιστευτήριο.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Cerrar la barra lateral después de abrir un host desde ella", "closeOnOpenDesc": "Cerrar la barra lateral después de abrir un host desde ella",
"showFolderPaths": "Mostrar rutas de carpetas", "showFolderPaths": "Mostrar rutas de carpetas",
"showFolderPathsDesc": "Muestra la ruta de la carpeta principal encima de los nombres de las subcarpetas anidadas. Las subcarpetas siguen estando sangradas de todos modos.", "showFolderPathsDesc": "Muestra la ruta de la carpeta principal encima de los nombres de las subcarpetas anidadas. Las subcarpetas siguen estando sangradas de todos modos.",
"stripedRows": "Colores alternos en las filas",
"stripedRowsDesc": "Aplicar un tono diferente a cada fila alterna en la lista de hosts",
"rowDetailsTitle": "Detalles de la fila", "rowDetailsTitle": "Detalles de la fila",
"rowDetailsDesc": "Elige qué se muestra en cada host.", "rowDetailsDesc": "Elige qué se muestra en cada host.",
"showAddress": "Dirección", "showAddress": "Dirección",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartido", "sharedBadge": "Compartido",
"sharedBadgeTooltip": "Compartido por {{owner}} (acceso {{level}})", "sharedBadgeTooltip": "Compartido por {{owner}} (acceso {{level}})",
"viewOnlyBanner": "Este host te ha sido compartido por {{owner}} con acceso de solo lectura. La configuración es de solo lectura.", "viewOnlyBanner": "Este host te ha sido compartido por {{owner}} con acceso de solo lectura. La configuración es de solo lectura.",
"folderRuleRemoveFromHosts": "También eliminar este acceso de todos los hosts de la carpeta y sus subcarpetas",
"sharedCopyBanner": "Este host te ha sido compartido por {{owner}}. El escritorio mantiene una copia de solo lectura, por lo que si lo modificas en el servidor, los cambios se sincronizarán aquí.",
"sharedEditBanner": "Este host te ha sido compartido por {{owner}} con acceso de edición. Los cambios se aplican al host real; solo el propietario puede modificar las referencias de autenticación.", "sharedEditBanner": "Este host te ha sido compartido por {{owner}} con acceso de edición. Los cambios se aplican al host real; solo el propietario puede modificar las referencias de autenticación.",
"ownerOnlyControl": "Solo el propietario del host puede cambiar esto", "ownerOnlyControl": "Solo el propietario del host puede cambiar esto",
"ownerAuthPrivate": "La autenticación SSH del propietario del host es privada. Usa «Establecer autenticación SSH personal» en el menú del host para elegir tu propia credencial.", "ownerAuthPrivate": "La autenticación SSH del propietario del host es privada. Usa «Establecer autenticación SSH personal» en el menú del host para elegir tu propia credencial.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Sulge külgriba pärast hosti sellest avamist", "closeOnOpenDesc": "Sulge külgriba pärast hosti sellest avamist",
"showFolderPaths": "Kuva kausta teed", "showFolderPaths": "Kuva kausta teed",
"showFolderPathsDesc": "Kuva vanemkausta tee sisestatud alamkaustade nimede kohal. Alamkaustad on igal juhul sisse tõstetud.", "showFolderPathsDesc": "Kuva vanemkausta tee sisestatud alamkaustade nimede kohal. Alamkaustad on igal juhul sisse tõstetud.",
"stripedRows": "Vahelduvad ridade värvid",
"stripedRowsDesc": "Varjunda iga teine rida hostide nimekirjas",
"rowDetailsTitle": "Rea detailid", "rowDetailsTitle": "Rea detailid",
"rowDetailsDesc": "Vali, mis iga hosti kohta kuvatakse.", "rowDetailsDesc": "Vali, mis iga hosti kohta kuvatakse.",
"showAddress": "Aadress", "showAddress": "Aadress",
@@ -775,6 +777,8 @@
"sharedBadge": "Jagatud", "sharedBadge": "Jagatud",
"sharedBadgeTooltip": "Jagatud kasutaja {{owner}} poolt ({{level}} ligipääs)", "sharedBadgeTooltip": "Jagatud kasutaja {{owner}} poolt ({{level}} ligipääs)",
"viewOnlyBanner": "See host on jagatud kasutaja {{owner}} poolt vaatamisõigusega. Konfiguratsioon on kirjutuskaitstud.", "viewOnlyBanner": "See host on jagatud kasutaja {{owner}} poolt vaatamisõigusega. Konfiguratsioon on kirjutuskaitstud.",
"folderRuleRemoveFromHosts": "Eemalda see ligipääs ka kõigilt hostidelt kaustas ja selle alamkaustades",
"sharedCopyBanner": "See host on jagatud sinuga kasutaja {{owner}} poolt. Töölaua rakendus hoiab lugemisõigusega koopiat, seega tee muudatused serveris ja need sünkroonitakse siia.",
"sharedEditBanner": "See host on jagatud kasutaja {{owner}} poolt muutmise õigusega. Muudatused rakenduvad tegelikule hostile; autentimise viiteid saab muuta ainult hosti omanik.", "sharedEditBanner": "See host on jagatud kasutaja {{owner}} poolt muutmise õigusega. Muudatused rakenduvad tegelikule hostile; autentimise viiteid saab muuta ainult hosti omanik.",
"ownerOnlyControl": "Seda saab muuta ainult hosti omanik", "ownerOnlyControl": "Seda saab muuta ainult hosti omanik",
"ownerAuthPrivate": "Hosti omaniku SSH-autentimine on privaatne. Kasuta hosti menüüst valikut „Määra isiklik SSH-autentimine“, et valida oma usalduskiri.", "ownerAuthPrivate": "Hosti omaniku SSH-autentimine on privaatne. Kasuta hosti menüüst valikut „Määra isiklik SSH-autentimine“, et valida oma usalduskiri.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Sulje sivupalkki, kun avaat isäntäsen palvelimen siitä", "closeOnOpenDesc": "Sulje sivupalkki, kun avaat isäntäsen palvelimen siitä",
"showFolderPaths": "Näytä kansioiden polut", "showFolderPaths": "Näytä kansioiden polut",
"showFolderPathsDesc": "Näytä vanhemman kansion polku sisäkkäisten alikansioiden nimien yläpuolella. Alikansiot pysyvät sisennettyinä joka tapauksessa.", "showFolderPathsDesc": "Näytä vanhemman kansion polku sisäkkäisten alikansioiden nimien yläpuolella. Alikansiot pysyvät sisennettyinä joka tapauksessa.",
"stripedRows": "Vaihtuvat rivin värit",
"stripedRowsDesc": "Varjostaa toinen toisensa seuraavat rivit isäntälistassa",
"rowDetailsTitle": "Rivin tiedot", "rowDetailsTitle": "Rivin tiedot",
"rowDetailsDesc": "Valitse, mitä näytetään jokaisessa isännässä.", "rowDetailsDesc": "Valitse, mitä näytetään jokaisessa isännässä.",
"showAddress": "Osoite", "showAddress": "Osoite",
@@ -775,6 +777,8 @@
"sharedBadge": "Julkaisu", "sharedBadge": "Julkaisu",
"sharedBadgeTooltip": "Julkaissut {{owner}} ({{level}}-käyttöoikeus)", "sharedBadgeTooltip": "Julkaissut {{owner}} ({{level}}-käyttöoikeus)",
"viewOnlyBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta katselukäyttöoikeudella. Konfiguraatio on vain luettavissa.", "viewOnlyBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta katselukäyttöoikeudella. Konfiguraatio on vain luettavissa.",
"folderRuleRemoveFromHosts": "Poista myös tämä pääsy kaikista kansiossa ja sen alikansioissa olevista isännöistä",
"sharedCopyBanner": "Tämä isäntä on jaettu sinulle käyttäjän {{owner}} toimesta. Työpöytä pitää luettavan kopion, joten tee muutokset palvelimella, ja ne synkronoituvat tänne.",
"sharedEditBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta muokkauskäyttöoikeudella. Muutokset vaikuttavat todelliseen isäntäkoneeseen; todennusviittauksia voi muuttaa vain omistaja.", "sharedEditBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta muokkauskäyttöoikeudella. Muutokset vaikuttavat todelliseen isäntäkoneeseen; todennusviittauksia voi muuttaa vain omistaja.",
"ownerOnlyControl": "Vain isäntäkoneen omistaja voi muuttaa tätä", "ownerOnlyControl": "Vain isäntäkoneen omistaja voi muuttaa tätä",
"ownerAuthPrivate": "Isäntäkoneen omistajan SSH-todennus on yksityinen. Valitse oma tunnistetietosi isäntävalikon kohdasta ”Aseta henkilökohtainen SSH-todennus”.", "ownerAuthPrivate": "Isäntäkoneen omistajan SSH-todennus on yksityinen. Valitse oma tunnistetietosi isäntävalikon kohdasta ”Aseta henkilökohtainen SSH-todennus”.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Fermer la barre latérale après avoir ouvert un hôte depuis celle-ci", "closeOnOpenDesc": "Fermer la barre latérale après avoir ouvert un hôte depuis celle-ci",
"showFolderPaths": "Afficher les chemins des dossiers", "showFolderPaths": "Afficher les chemins des dossiers",
"showFolderPathsDesc": "Afficher le chemin du dossier parent au-dessus des noms des sous-dossiers imbriqués. Les sous-dossiers restent indentés dans les deux cas.", "showFolderPathsDesc": "Afficher le chemin du dossier parent au-dessus des noms des sous-dossiers imbriqués. Les sous-dossiers restent indentés dans les deux cas.",
"stripedRows": "Couleurs de lignes alternées",
"stripedRowsDesc": "Ombrer chaque ligne sur deux dans la liste des hôtes",
"rowDetailsTitle": "Détails de la ligne", "rowDetailsTitle": "Détails de la ligne",
"rowDetailsDesc": "Choisissez ce qui s'affiche pour chaque hôte.", "rowDetailsDesc": "Choisissez ce qui s'affiche pour chaque hôte.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -787,6 +789,8 @@
"sharedBadge": "Partagé", "sharedBadge": "Partagé",
"sharedBadgeTooltip": "Partagé par {{owner}} (accès {{level}})", "sharedBadgeTooltip": "Partagé par {{owner}} (accès {{level}})",
"viewOnlyBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en lecture seule. La configuration est en lecture seule.", "viewOnlyBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en lecture seule. La configuration est en lecture seule.",
"folderRuleRemoveFromHosts": "Supprimer également cet accès de chaque hôte dans le dossier et ses sous-dossiers",
"sharedCopyBanner": "Cet hôte vous est partagé par {{owner}}. Le bureau conserve une copie en lecture seule ; modifiez-le sur le serveur et il sera synchronisé ici.",
"sharedEditBanner": "Cet hôte vous est partagé par {{owner}} avec 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.", "sharedEditBanner": "Cet hôte vous est partagé par {{owner}} avec 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 ceci", "ownerOnlyControl": "Seul le propriétaire de l'hôte peut modifier ceci",
"ownerAuthPrivate": "L'authentification SSH du propriétaire de l'hôte est privée. Utilisez « Définir l'authentification SSH personnelle » dans le menu de l'hôte pour choisir vos propres identifiants.", "ownerAuthPrivate": "L'authentification SSH du propriétaire de l'hôte est privée. Utilisez « Définir l'authentification SSH personnelle » dans le menu de l'hôte pour choisir vos propres identifiants.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו", "closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו",
"showFolderPaths": "הצג נתיבי תיקייה", "showFolderPaths": "הצג נתיבי תיקייה",
"showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.", "showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.",
"stripedRows": "צבעי שורות מתחלפים",
"stripedRowsDesc": "הצלל כל שורה שנייה ברשימת המארחים",
"rowDetailsTitle": "פרטי שורה", "rowDetailsTitle": "פרטי שורה",
"rowDetailsDesc": "בחר מה יופיע בכל מארח.", "rowDetailsDesc": "בחר מה יופיע בכל מארח.",
"showAddress": "כתובת", "showAddress": "כתובת",
@@ -787,6 +789,8 @@
"sharedBadge": "משותף", "sharedBadge": "משותף",
"sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה {{level}})", "sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה {{level}})",
"viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לצפייה בלבד. התצורה היא לקריאה בלבד.", "viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לצפייה בלבד. התצורה היא לקריאה בלבד.",
"folderRuleRemoveFromHosts": "הסר גם גישה זו מכל מארח בתיקייה ובתיקיות המשנה שלה",
"sharedCopyBanner": "מארח זה שותף איתך על ידי {{owner}}. השולחן עבודה שומר עותק לקריאה בלבד, כך ששינויים במארח יסונכרנו לכאן.",
"sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לעריכה. שינויים מוחלים על המארח האמיתי; הפניות לאימות יכולות להשתנות רק על ידי הבעלים.", "sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לעריכה. שינויים מוחלים על המארח האמיתי; הפניות לאימות יכולות להשתנות רק על ידי הבעלים.",
"ownerOnlyControl": "רק בעל המארח יכול לשנות זאת", "ownerOnlyControl": "רק בעל המארח יכול לשנות זאת",
"ownerAuthPrivate": "אימות ה-SSH של בעל המארח הוא פרטי. השתמש ב-\"הגדר אימות SSH אישי\" מתפריט המארח כדי לבחור אישור משלך.", "ownerAuthPrivate": "אימות ה-SSH של בעל המארח הוא פרטי. השתמש ב-\"הגדר אימות SSH אישי\" מתפריט המארח כדי לבחור אישור משלך.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "इससे होस्ट खोलने के बाद साइडबार बंद करें", "closeOnOpenDesc": "इससे होस्ट खोलने के बाद साइडबार बंद करें",
"showFolderPaths": "फ़ोल्डर पथ दिखाएं", "showFolderPaths": "फ़ोल्डर पथ दिखाएं",
"showFolderPathsDesc": "नेस्टेड सबफ़ोल्डर नामों के ऊपर माता-पिता फ़ोल्डर का पथ दिखाएं। सबफ़ोल्डर दोनों स्थिति में अंतर्निहित रहते हैं।", "showFolderPathsDesc": "नेस्टेड सबफ़ोल्डर नामों के ऊपर माता-पिता फ़ोल्डर का पथ दिखाएं। सबफ़ोल्डर दोनों स्थिति में अंतर्निहित रहते हैं।",
"stripedRows": "बारी-बारी से पंक्ति के रंग",
"stripedRowsDesc": "होस्ट सूची में हर दूसरी पंक्ति को छाया दें",
"rowDetailsTitle": "पंक्ति विवरण", "rowDetailsTitle": "पंक्ति विवरण",
"rowDetailsDesc": "चुनें कि प्रत्येक होस्ट पर क्या दिखाया जाए।", "rowDetailsDesc": "चुनें कि प्रत्येक होस्ट पर क्या दिखाया जाए।",
"showAddress": "पता", "showAddress": "पता",
@@ -775,6 +777,8 @@
"sharedBadge": "साझा किया गया", "sharedBadge": "साझा किया गया",
"sharedBadgeTooltip": "{{owner}} द्वारा साझा किया गया ({{level}} एक्सेस)", "sharedBadgeTooltip": "{{owner}} द्वारा साझा किया गया ({{level}} एक्सेस)",
"viewOnlyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ देखने के एक्सेस के साथ साझा किया गया है। कॉन्फ़िगरेशन रीड-ओनली है।", "viewOnlyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ देखने के एक्सेस के साथ साझा किया गया है। कॉन्फ़िगरेशन रीड-ओनली है।",
"folderRuleRemoveFromHosts": "इस फ़ोल्डर और इसके सबफ़ोल्डर्स में मौजूद हर होस्ट से भी यह एक्सेस हटाएं",
"sharedCopyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ शेयर किया गया है। डेस्कटॉप इसका रीड-ओनली कॉपी रखता है, इसलिए सर्वर पर बदलाव करें और यह यहाँ सिंक हो जाएगा।",
"sharedEditBanner": "यह होस्ट {{owner}} द्वारा आपके साथ एडिट एक्सेस के साथ साझा किया गया है। परिवर्तन असली होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भों को केवल मालिक ही बदल सकता है।", "sharedEditBanner": "यह होस्ट {{owner}} द्वारा आपके साथ एडिट एक्सेस के साथ साझा किया गया है। परिवर्तन असली होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भों को केवल मालिक ही बदल सकता है।",
"ownerOnlyControl": "केवल होस्ट मालिक ही इसे बदल सकता है", "ownerOnlyControl": "केवल होस्ट मालिक ही इसे बदल सकता है",
"ownerAuthPrivate": "होस्ट मालिक का SSH प्रमाणीकरण निजी है। अपना क्रेडेंशियल चुनने के लिए होस्ट मेनू से “व्यक्तिगत SSH प्रमाणीकरण सेट करें” का उपयोग करें।", "ownerAuthPrivate": "होस्ट मालिक का SSH प्रमाणीकरण निजी है। अपना क्रेडेंशियल चुनने के लिए होस्ट मेनू से “व्यक्तिगत SSH प्रमाणीकरण सेट करें” का उपयोग करें।",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Zatvori bočnu traku nakon otvaranja hosta iz nje", "closeOnOpenDesc": "Zatvori bočnu traku nakon otvaranja hosta iz nje",
"showFolderPaths": "Prikaži putanje mapa", "showFolderPaths": "Prikaži putanje mapa",
"showFolderPathsDesc": "Prikažite putanju nadređene mape iznad naziva ugniježđenih podmapa. Podmape ostaju uvučene u svakom slučaju.", "showFolderPathsDesc": "Prikažite putanju nadređene mape iznad naziva ugniježđenih podmapa. Podmape ostaju uvučene u svakom slučaju.",
"stripedRows": "Naizmjenične boje redaka",
"stripedRowsDesc": "Obojite svaki drugi redak u popisu hostova",
"rowDetailsTitle": "Detalji retka", "rowDetailsTitle": "Detalji retka",
"rowDetailsDesc": "Odaberite što se prikazuje za svaki host.", "rowDetailsDesc": "Odaberite što se prikazuje za svaki host.",
"showAddress": "Adresa", "showAddress": "Adresa",
@@ -787,6 +789,8 @@
"sharedBadge": "Dijeljeno", "sharedBadge": "Dijeljeno",
"sharedBadgeTooltip": "Dijeli {{owner}} (razina pristupa: {{level}})", "sharedBadgeTooltip": "Dijeli {{owner}} (razina pristupa: {{level}})",
"viewOnlyBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za pregled. Konfiguracija je samo za čitanje.", "viewOnlyBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za pregled. Konfiguracija je samo za čitanje.",
"folderRuleRemoveFromHosts": "Ukloni i ovaj pristup sa svih hostova u ovoj mapi i podmapama",
"sharedCopyBanner": "Ovaj host dijeli s vama {{owner}}. Desktop zadržava kopiju samo za čitanje, pa ga promijenite na poslužitelju i sinkronizirat će se ovdje.",
"sharedEditBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za uređivanje. Promjene se primjenjuju na stvarni host; reference za autentifikaciju može mijenjati samo vlasnik.", "sharedEditBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za uređivanje. Promjene se primjenjuju na stvarni host; reference za autentifikaciju može mijenjati samo vlasnik.",
"ownerOnlyControl": "Ovo može promijeniti samo vlasnik hosta", "ownerOnlyControl": "Ovo može promijeniti samo vlasnik hosta",
"ownerAuthPrivate": "SSH autentifikacija vlasnika hosta je privatna. Odaberite vlastite vjerodajnice putem opcije „Postavi osobnu SSH autentifikaciju“ u izborniku hosta.", "ownerAuthPrivate": "SSH autentifikacija vlasnika hosta je privatna. Odaberite vlastite vjerodajnice putem opcije „Postavi osobnu SSH autentifikaciju“ u izborniku hosta.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Az oldalsáv bezárása egy gazdagép megnyitása után", "closeOnOpenDesc": "Az oldalsáv bezárása egy gazdagép megnyitása után",
"showFolderPaths": "Mappa-utak megjelenítése", "showFolderPaths": "Mappa-utak megjelenítése",
"showFolderPathsDesc": "A szülőmappa-út megjelenítése az egymásba ágyazott almappanevek felett. Az almappák mindkét esetben behúzásban maradnak.", "showFolderPathsDesc": "A szülőmappa-út megjelenítése az egymásba ágyazott almappanevek felett. Az almappák mindkét esetben behúzásban maradnak.",
"stripedRows": "Váltakozó sorszínek",
"stripedRowsDesc": "A hosztlista minden második sorának árnyékolása",
"rowDetailsTitle": "Sor részletei", "rowDetailsTitle": "Sor részletei",
"rowDetailsDesc": "Válassza ki, mi jelenjen meg minden kiszolgálónál.", "rowDetailsDesc": "Válassza ki, mi jelenjen meg minden kiszolgálónál.",
"showAddress": "Cím", "showAddress": "Cím",
@@ -775,6 +777,8 @@
"sharedBadge": "Megosztott", "sharedBadge": "Megosztott",
"sharedBadgeTooltip": "Megosztotta: {{owner}} ({{level}} hozzáférés)", "sharedBadgeTooltip": "Megosztotta: {{owner}} ({{level}} hozzáférés)",
"viewOnlyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled megtekintési jogosultsággal. A konfiguráció csak olvasható.", "viewOnlyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled megtekintési jogosultsággal. A konfiguráció csak olvasható.",
"folderRuleRemoveFromHosts": "Az engedélyt eltávolítja a mappában és almappáiban lévő összes hosztról is",
"sharedCopyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled. Az asztali alkalmazás egy csak olvasható másolatot tart meg, így ha a szerveren módosítod, a változás szinkronizálódik ide.",
"sharedEditBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled szerkesztési jogosultsággal. A módosítások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.", "sharedEditBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled szerkesztési jogosultsággal. A módosítások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.",
"ownerOnlyControl": "Ezt csak a hoszt tulajdonosa módosíthatja", "ownerOnlyControl": "Ezt csak a hoszt tulajdonosa módosíthatja",
"ownerAuthPrivate": "A hoszt tulajdonosa SSH-hitelesítése privát. A saját hitelesítő adataid kiválasztásához használd a „Saját SSH-hitelesítés beállítása” menüpontot a hoszt menüjében.", "ownerAuthPrivate": "A hoszt tulajdonosa SSH-hitelesítése privát. A saját hitelesítő adataid kiválasztásához használd a „Saját SSH-hitelesítés beállítása” menüpontot a hoszt menüjében.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "Tutup bilah sisi setelah Anda membuka host darinya", "closeOnOpenDesc": "Tutup bilah sisi setelah Anda membuka host darinya",
"showFolderPaths": "Tampilkan jalur folder", "showFolderPaths": "Tampilkan jalur folder",
"showFolderPathsDesc": "Tampilkan jalur folder induk di atas nama subfolder bersarang. Subfolder tetap menjorok ke dalam dalam kedua kasus.", "showFolderPathsDesc": "Tampilkan jalur folder induk di atas nama subfolder bersarang. Subfolder tetap menjorok ke dalam dalam kedua kasus.",
"stripedRows": "Warna baris bergantian",
"stripedRowsDesc": "Berikan warna pada setiap baris kedua di daftar host",
"rowDetailsTitle": "Detail baris", "rowDetailsTitle": "Detail baris",
"rowDetailsDesc": "Pilih apa yang ditampilkan pada setiap host.", "rowDetailsDesc": "Pilih apa yang ditampilkan pada setiap host.",
"showAddress": "Alamat", "showAddress": "Alamat",
@@ -763,6 +765,8 @@
"sharedBadge": "Dibagikan", "sharedBadge": "Dibagikan",
"sharedBadgeTooltip": "Dibagikan oleh {{owner}} (akses {{level}})", "sharedBadgeTooltip": "Dibagikan oleh {{owner}} (akses {{level}})",
"viewOnlyBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses lihat. Konfigurasi bersifat baca-saja.", "viewOnlyBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses lihat. Konfigurasi bersifat baca-saja.",
"folderRuleRemoveFromHosts": "Hapus akses ini juga dari setiap host di folder dan subfoldernya",
"sharedCopyBanner": "Host ini dibagikan kepada Anda oleh {{owner}}. Desktop menyimpan salinan hanya-baca, sehingga ubah di server dan akan disinkronkan di sini.",
"sharedEditBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses edit. Perubahan diterapkan pada host asli; referensi autentikasi hanya dapat diubah oleh pemilik.", "sharedEditBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses edit. Perubahan diterapkan pada host asli; referensi autentikasi hanya dapat diubah oleh pemilik.",
"ownerOnlyControl": "Hanya pemilik host yang dapat mengubah ini", "ownerOnlyControl": "Hanya pemilik host yang dapat mengubah ini",
"ownerAuthPrivate": "Autentikasi SSH pemilik host bersifat pribadi. Gunakan “Atur autentikasi SSH pribadi” dari menu host untuk memilih kredensial Anda sendiri.", "ownerAuthPrivate": "Autentikasi SSH pemilik host bersifat pribadi. Gunakan “Atur autentikasi SSH pribadi” dari menu host untuk memilih kredensial Anda sendiri.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Chiudi la barra laterale dopo aver aperto un host da essa", "closeOnOpenDesc": "Chiudi la barra laterale dopo aver aperto un host da essa",
"showFolderPaths": "Mostra percorsi delle cartelle", "showFolderPaths": "Mostra percorsi delle cartelle",
"showFolderPathsDesc": "Mostra il percorso della cartella genitore sopra i nomi delle sottocartelle annidate. Le sottocartelle rimangono indentate in ogni caso.", "showFolderPathsDesc": "Mostra il percorso della cartella genitore sopra i nomi delle sottocartelle annidate. Le sottocartelle rimangono indentate in ogni caso.",
"stripedRows": "Colori delle righe alternati",
"stripedRowsDesc": "Applica un'ombra a ogni altra riga nell'elenco degli host",
"rowDetailsTitle": "Dettagli riga", "rowDetailsTitle": "Dettagli riga",
"rowDetailsDesc": "Scegli cosa mostrare per ogni host.", "rowDetailsDesc": "Scegli cosa mostrare per ogni host.",
"showAddress": "Indirizzo", "showAddress": "Indirizzo",
@@ -787,6 +789,8 @@
"sharedBadge": "Condiviso", "sharedBadge": "Condiviso",
"sharedBadgeTooltip": "Condiviso da {{owner}} (accesso {{level}})", "sharedBadgeTooltip": "Condiviso da {{owner}} (accesso {{level}})",
"viewOnlyBanner": "Questo host è stato condiviso con te da {{owner}} con accesso in sola lettura. La configurazione è di sola lettura.", "viewOnlyBanner": "Questo host è stato condiviso con te da {{owner}} con accesso in sola lettura. La configurazione è di sola lettura.",
"folderRuleRemoveFromHosts": "Rimuovi anche questo accesso da tutti gli host nella cartella e nelle sue sottocartelle",
"sharedCopyBanner": "Questo host è condiviso con te da {{owner}}. Il desktop mantiene una copia in sola lettura, quindi modificalo sul server e le modifiche verranno sincronizzate qui.",
"sharedEditBanner": "Questo host è stato condiviso con te da {{owner}} con accesso di modifica. Le modifiche si applicano all'host reale; i riferimenti di autenticazione possono essere modificati solo dal proprietario.", "sharedEditBanner": "Questo host è stato condiviso con te da {{owner}} con accesso di 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 elemento", "ownerOnlyControl": "Solo il proprietario dell'host può modificare questo elemento",
"ownerAuthPrivate": "L'autenticazione SSH del proprietario dell'host è privata. Usa “Imposta autenticazione SSH personale” dal menu dell'host per scegliere le tue credenziali.", "ownerAuthPrivate": "L'autenticazione SSH del proprietario dell'host è privata. Usa “Imposta autenticazione SSH personale” dal menu dell'host per scegliere le tue credenziali.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "サイドバーからホストを開いた後、サイドバーを閉じます", "closeOnOpenDesc": "サイドバーからホストを開いた後、サイドバーを閉じます",
"showFolderPaths": "フォルダパスを表示", "showFolderPaths": "フォルダパスを表示",
"showFolderPathsDesc": "ネストされたサブフォルダ名の上部に親フォルダのパスを表示します。サブフォルダはどちらの場合もインデントされます。", "showFolderPathsDesc": "ネストされたサブフォルダ名の上部に親フォルダのパスを表示します。サブフォルダはどちらの場合もインデントされます。",
"stripedRows": "交互行背景色",
"stripedRowsDesc": "在主机列表中为每隔一行添加背景色",
"rowDetailsTitle": "行の詳細", "rowDetailsTitle": "行の詳細",
"rowDetailsDesc": "各ホストに表示する内容を選択します。", "rowDetailsDesc": "各ホストに表示する内容を選択します。",
"showAddress": "アドレス", "showAddress": "アドレス",
@@ -763,6 +765,8 @@
"sharedBadge": "共有済み", "sharedBadge": "共有済み",
"sharedBadgeTooltip": "{{owner}} によって共有({{level}} アクセス)", "sharedBadgeTooltip": "{{owner}} によって共有({{level}} アクセス)",
"viewOnlyBanner": "このホストは {{owner}} によって閲覧アクセスで共有されています。設定は読み取り専用です。", "viewOnlyBanner": "このホストは {{owner}} によって閲覧アクセスで共有されています。設定は読み取り専用です。",
"folderRuleRemoveFromHosts": "同时从该文件夹及其子文件夹中的所有主机中移除此访问权限",
"sharedCopyBanner": "此主机由 {{owner}} 与您共享。桌面端保留只读副本,因此在服务器上修改后,此处将同步更新。",
"sharedEditBanner": "このホストは {{owner}} によって編集アクセスで共有されています。変更は実際のホストに反映されますが、認証参照は所有者のみが変更できます。", "sharedEditBanner": "このホストは {{owner}} によって編集アクセスで共有されています。変更は実際のホストに反映されますが、認証参照は所有者のみが変更できます。",
"ownerOnlyControl": "この変更はホストの所有者のみが行えます", "ownerOnlyControl": "この変更はホストの所有者のみが行えます",
"ownerAuthPrivate": "ホスト所有者の SSH 認証情報は非公開です。ホストメニューから「個人用 SSH 認証を設定」を選択して、独自の認証情報を使用してください。", "ownerAuthPrivate": "ホスト所有者の SSH 認証情報は非公開です。ホストメニューから「個人用 SSH 認証を設定」を選択して、独自の認証情報を使用してください。",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "사이드바에서 호스트를 열면 사이드바를 닫습니다", "closeOnOpenDesc": "사이드바에서 호스트를 열면 사이드바를 닫습니다",
"showFolderPaths": "폴더 경로 표시", "showFolderPaths": "폴더 경로 표시",
"showFolderPathsDesc": "중첩된 하위 폴더 이름 위에 상위 폴더 경로를 표시합니다. 하위 폴더는 어느 경우든 들여쓰기가 유지됩니다.", "showFolderPathsDesc": "중첩된 하위 폴더 이름 위에 상위 폴더 경로를 표시합니다. 하위 폴더는 어느 경우든 들여쓰기가 유지됩니다.",
"stripedRows": "줄무늬 행 색상",
"stripedRowsDesc": "호스트 목록에서 행을 번갈아 가며 색칠합니다",
"rowDetailsTitle": "행 세부 정보", "rowDetailsTitle": "행 세부 정보",
"rowDetailsDesc": "각 호스트에 표시할 내용을 선택하세요.", "rowDetailsDesc": "각 호스트에 표시할 내용을 선택하세요.",
"showAddress": "주소", "showAddress": "주소",
@@ -763,6 +765,8 @@
"sharedBadge": "공유됨", "sharedBadge": "공유됨",
"sharedBadgeTooltip": "{{owner}}님이 공유함 ({{level}} 접근 권한)", "sharedBadgeTooltip": "{{owner}}님이 공유함 ({{level}} 접근 권한)",
"viewOnlyBanner": "이 호스트는 {{owner}}님이 보기 전용 권한으로 공유했습니다. 설정은 읽기 전용입니다.", "viewOnlyBanner": "이 호스트는 {{owner}}님이 보기 전용 권한으로 공유했습니다. 설정은 읽기 전용입니다.",
"folderRuleRemoveFromHosts": "이 폴더와 하위 폴더의 모든 호스트에서도 이 접근 권한을 제거합니다",
"sharedCopyBanner": "이 호스트는 {{owner}}님이 공유한 것입니다. 데스크톱은 읽기 전용 복사본을 유지하므로, 서버에서 변경하면 여기로 동기화됩니다.",
"sharedEditBanner": "이 호스트는 {{owner}}님이 수정 권한으로 공유했습니다. 변경 사항은 실제 호스트에 적용되며, 인증 참조는 소유자만 변경할 수 있습니다.", "sharedEditBanner": "이 호스트는 {{owner}}님이 수정 권한으로 공유했습니다. 변경 사항은 실제 호스트에 적용되며, 인증 참조는 소유자만 변경할 수 있습니다.",
"ownerOnlyControl": "호스트 소유자만 이 항목을 변경할 수 있습니다", "ownerOnlyControl": "호스트 소유자만 이 항목을 변경할 수 있습니다",
"ownerAuthPrivate": "호스트 소유자의 SSH 인증 정보는 비공개입니다. 호스트 메뉴에서 '개인 SSH 인증 설정'을 사용하여 본인의 자격 증명을 선택하세요.", "ownerAuthPrivate": "호스트 소유자의 SSH 인증 정보는 비공개입니다. 호스트 메뉴에서 '개인 SSH 인증 설정'을 사용하여 본인의 자격 증명을 선택하세요.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Uždaryti šoninę juostą, kai atidarote priimtuvą iš jos", "closeOnOpenDesc": "Uždaryti šoninę juostą, kai atidarote priimtuvą iš jos",
"showFolderPaths": "Rodyti aplanko kelius", "showFolderPaths": "Rodyti aplanko kelius",
"showFolderPathsDesc": "Rodyti tėvinio aplanko kelią virš įdėto poaplanko pavadinimo. Poaplanai vis tiek lieka įtraukti.", "showFolderPathsDesc": "Rodyti tėvinio aplanko kelią virš įdėto poaplanko pavadinimo. Poaplanai vis tiek lieka įtraukti.",
"stripedRows": "Kintančios eilučių spalvos",
"stripedRowsDesc": "Paryškinti kas antrą eilutę serverių sąraše",
"rowDetailsTitle": "Eilutės detalės", "rowDetailsTitle": "Eilutės detalės",
"rowDetailsDesc": "Pasirinkite, kas rodoma kiekviename serverio įraše.", "rowDetailsDesc": "Pasirinkite, kas rodoma kiekviename serverio įraše.",
"showAddress": "Adresas", "showAddress": "Adresas",
@@ -799,6 +801,8 @@
"sharedBadge": "Bendrinamas", "sharedBadge": "Bendrinamas",
"sharedBadgeTooltip": "Bendrinama nuo {{owner}} ({{level}} prieiga)", "sharedBadgeTooltip": "Bendrinama nuo {{owner}} ({{level}} prieiga)",
"viewOnlyBanner": "Šis serveris bendrinamas su jumis nuo {{owner}} su peržiūros prieiga. Konfigūracija yra tik skaitymo režimu.", "viewOnlyBanner": "Šis serveris bendrinamas su jumis nuo {{owner}} su peržiūros prieiga. Konfigūracija yra tik skaitymo režimu.",
"folderRuleRemoveFromHosts": "Taip pat pašalinti šią prieigą iš visų serverių aplankelyje ir jo poaplankeliuose",
"sharedCopyBanner": "Šis serveris su jumis dalijasi {{owner}}. Darbalaukis saugo tik skaitymui skirtą kopiją, todėl pakeitimai atliekami serveryje ir sinchronizuojami čia.",
"sharedEditBanner": "Šis serveris bendrinamas su jumis nuo {{owner}} su redagavimo prieiga. Pakeitimai taikomi tikram serveriui; autentifikacijos nuorodas gali keisti tik savininkas.", "sharedEditBanner": "Šis serveris bendrinamas su jumis nuo {{owner}} su redagavimo prieiga. Pakeitimai taikomi tikram serveriui; autentifikacijos nuorodas gali keisti tik savininkas.",
"ownerOnlyControl": "Tai gali pakeisti tik serverio savininkas", "ownerOnlyControl": "Tai gali pakeisti tik serverio savininkas",
"ownerAuthPrivate": "Serverio savininko SSH autentifikacija yra privati. Naudokite „Nustatyti asmeninę SSH autentifikaciją“ iš serverio meniu, kad pasirinktumėte savo prisijungimo duomenis.", "ownerAuthPrivate": "Serverio savininko SSH autentifikacija yra privati. Naudokite „Nustatyti asmeninę SSH autentifikaciją“ iš serverio meniu, kad pasirinktumėte savo prisijungimo duomenis.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Aizvērt sānjoslu pēc tam, kad no tās esat atvēris mezglu", "closeOnOpenDesc": "Aizvērt sānjoslu pēc tam, kad no tās esat atvēris mezglu",
"showFolderPaths": "Rādīt mapju ceļus", "showFolderPaths": "Rādīt mapju ceļus",
"showFolderPathsDesc": "Rādīt vecākmātes mapes ceļu virs iekšējām apakšmapēm. Apakšmapes joprojām ir atkārtas.", "showFolderPathsDesc": "Rādīt vecākmātes mapes ceļu virs iekšējām apakšmapēm. Apakšmapes joprojām ir atkārtas.",
"stripedRows": "Mainojošu rindu krāsas",
"stripedRowsDesc": "Iekrāsot katru otro rindu resursu sarakstā",
"rowDetailsTitle": "Rindas detaļas", "rowDetailsTitle": "Rindas detaļas",
"rowDetailsDesc": "Izvēlieties, kas tiek rādīts katram mezglam.", "rowDetailsDesc": "Izvēlieties, kas tiek rādīts katram mezglam.",
"showAddress": "Adrese", "showAddress": "Adrese",
@@ -787,6 +789,8 @@
"sharedBadge": "Kopīgots", "sharedBadge": "Kopīgots",
"sharedBadgeTooltip": "Kopīgojis {{owner}} ({{level}} piekļuve)", "sharedBadgeTooltip": "Kopīgojis {{owner}} ({{level}} piekļuve)",
"viewOnlyBanner": "Šo hostu jums ir kopīgojis {{owner}} ar skatīšanas piekļuvi. Konfigurācija ir tikai lasāma.", "viewOnlyBanner": "Šo hostu jums ir kopīgojis {{owner}} ar skatīšanas piekļuvi. Konfigurācija ir tikai lasāma.",
"folderRuleRemoveFromHosts": "Noņemt šo piekļuvi arī no visiem resursiem šajā mapē un tās apakšmapēs",
"sharedCopyBanner": "Šis resurss ir kopīgoti ar jums no {{owner}}. Darbvirsmā tiek saglabāta tikai lasāma kopija, tāpēc veiciet izmaiņas serverī, un tās sinhronizēsies šeit.",
"sharedEditBanner": "Šo hostu jums ir kopīgojis {{owner}} ar rediģēšanas piekļuvi. Izmaiņas tiek piemērotas faktiskajam hostam; autentifikācijas atsauces var mainīt tikai īpašnieks.", "sharedEditBanner": "Šo hostu jums ir kopīgojis {{owner}} ar rediģēšanas piekļuvi. Izmaiņas tiek piemērotas faktiskajam hostam; autentifikācijas atsauces var mainīt tikai īpašnieks.",
"ownerOnlyControl": "To var mainīt tikai hosta īpašnieks", "ownerOnlyControl": "To var mainīt tikai hosta īpašnieks",
"ownerAuthPrivate": "Hosta īpašnieka SSH autentifikācija ir privāta. Izmantojiet opciju “Iestatīt personīgo SSH autentifikāciju” hosta izvēlnē, lai izvēlētos savus pieteikšanās datus.", "ownerAuthPrivate": "Hosta īpašnieka SSH autentifikācija ir privāta. Izmantojiet opciju “Iestatīt personīgo SSH autentifikāciju” hosta izvēlnē, lai izvēlētos savus pieteikšanās datus.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "Tutup bar sisi selepas anda membuka hos daripadanya", "closeOnOpenDesc": "Tutup bar sisi selepas anda membuka hos daripadanya",
"showFolderPaths": "Paparkan laluan folder", "showFolderPaths": "Paparkan laluan folder",
"showFolderPathsDesc": "Paparkan laluan folder induk di atas nama subfolder bersarang. Subfolder kekal bersebelah sama ada pilihan ini diaktifkan atau tidak.", "showFolderPathsDesc": "Paparkan laluan folder induk di atas nama subfolder bersarang. Subfolder kekal bersebelah sama ada pilihan ini diaktifkan atau tidak.",
"stripedRows": "Warna baris berselang-seli",
"stripedRowsDesc": "Warnakan setiap baris kedua dalam senarai hos",
"rowDetailsTitle": "Butiran baris", "rowDetailsTitle": "Butiran baris",
"rowDetailsDesc": "Pilih apa yang dipaparkan pada setiap hos.", "rowDetailsDesc": "Pilih apa yang dipaparkan pada setiap hos.",
"showAddress": "Alamat", "showAddress": "Alamat",
@@ -763,6 +765,8 @@
"sharedBadge": "Dikongsi", "sharedBadge": "Dikongsi",
"sharedBadgeTooltip": "Dikongsi oleh {{owner}} (akses {{level}})", "sharedBadgeTooltip": "Dikongsi oleh {{owner}} (akses {{level}})",
"viewOnlyBanner": "Hos ini dikongsi dengan anda oleh {{owner}} dengan akses paparan. Konfigurasi adalah baca-sahaja.", "viewOnlyBanner": "Hos ini dikongsi dengan anda oleh {{owner}} dengan akses paparan. Konfigurasi adalah baca-sahaja.",
"folderRuleRemoveFromHosts": "Juga buang akses ini daripada setiap hos dalam folder dan subfoldernya",
"sharedCopyBanner": "Hos ini dikongsi dengan anda oleh {{owner}}. Desktop menyimpan salinan baca sahaja, jadi ubah pada pelayan dan ia akan disegerakkan di sini.",
"sharedEditBanner": "Hos ini dikongsi dengan anda oleh {{owner}} dengan akses sunting. Perubahan akan digunakan pada hos sebenar; rujukan pengesahan hanya boleh diubah oleh pemilik.", "sharedEditBanner": "Hos ini dikongsi dengan anda oleh {{owner}} dengan akses sunting. Perubahan akan digunakan pada hos sebenar; rujukan pengesahan hanya boleh diubah oleh pemilik.",
"ownerOnlyControl": "Hanya pemilik hos boleh mengubah ini", "ownerOnlyControl": "Hanya pemilik hos boleh mengubah ini",
"ownerAuthPrivate": "Pengesahan SSH pemilik hos adalah peribadi. Gunakan “Tetapkan pengesahan SSH peribadi” dari menu hos untuk memilih kelayakan anda sendiri.", "ownerAuthPrivate": "Pengesahan SSH pemilik hos adalah peribadi. Gunakan “Tetapkan pengesahan SSH peribadi” dari menu hos untuk memilih kelayakan anda sendiri.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Sluit de zijbalk nadat je een host vanuit de zijbalk hebt geopend", "closeOnOpenDesc": "Sluit de zijbalk nadat je een host vanuit de zijbalk hebt geopend",
"showFolderPaths": "Mappaden tonen", "showFolderPaths": "Mappaden tonen",
"showFolderPathsDesc": "Toon het pad van de bovenliggende map boven geneste submapnamen. Submappen blijven in beide gevallen ingesprongen.", "showFolderPathsDesc": "Toon het pad van de bovenliggende map boven geneste submapnamen. Submappen blijven in beide gevallen ingesprongen.",
"stripedRows": "Afwisselende rijkleuren",
"stripedRowsDesc": "Elke tweede rij in de hostlijst schaduwen",
"rowDetailsTitle": "Regelgegevens", "rowDetailsTitle": "Regelgegevens",
"rowDetailsDesc": "Kies wat er bij elke host wordt getoond.", "rowDetailsDesc": "Kies wat er bij elke host wordt getoond.",
"showAddress": "Adres", "showAddress": "Adres",
@@ -775,6 +777,8 @@
"sharedBadge": "Gedeeld", "sharedBadge": "Gedeeld",
"sharedBadgeTooltip": "Gedeeld door {{owner}} ({{level}}-toegang)", "sharedBadgeTooltip": "Gedeeld door {{owner}} ({{level}}-toegang)",
"viewOnlyBanner": "Deze host is met jou gedeeld door {{owner}} met weergavetoegang. De configuratie is alleen-lezen.", "viewOnlyBanner": "Deze host is met jou gedeeld door {{owner}} met weergavetoegang. De configuratie is alleen-lezen.",
"folderRuleRemoveFromHosts": "Verwijder deze toegang ook van elke host in de map en de submappen",
"sharedCopyBanner": "Deze host is met jou gedeeld door {{owner}}. De desktop bewaart een alleen-lezen kopie, dus pas het aan op de server en het wordt hier gesynchroniseerd.",
"sharedEditBanner": "Deze host is met jou gedeeld door {{owner}} met bewerktoegang. Wijzigingen worden toegepast op de echte host; authenticatieverwijzingen kunnen alleen door de eigenaar worden gewijzigd.", "sharedEditBanner": "Deze host is met jou gedeeld door {{owner}} met bewerktoegang. Wijzigingen worden toegepast op de echte host; authenticatieverwijzingen kunnen alleen door de eigenaar worden gewijzigd.",
"ownerOnlyControl": "Alleen de eigenaar van de host kan dit wijzigen", "ownerOnlyControl": "Alleen de eigenaar van de host kan dit wijzigen",
"ownerAuthPrivate": "De SSH-authenticatie van de hosteigenaar is privé. Gebruik “Persoonlijke SSH-authenticatie instellen” in het hostmenu om je eigen referenties te kiezen.", "ownerAuthPrivate": "De SSH-authenticatie van de hosteigenaar is privé. Gebruik “Persoonlijke SSH-authenticatie instellen” in het hostmenu om je eigen referenties te kiezen.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Lukk sidefeltet etter at du har åpnet en vert fra det", "closeOnOpenDesc": "Lukk sidefeltet etter at du har åpnet en vert fra det",
"showFolderPaths": "Vis mappestier", "showFolderPaths": "Vis mappestier",
"showFolderPathsDesc": "Vis overordnede mappet over navnene på innrykkede undermapper. Undermapper forblir innrykket uansett.", "showFolderPathsDesc": "Vis overordnede mappet over navnene på innrykkede undermapper. Undermapper forblir innrykket uansett.",
"stripedRows": "Veksle farge på rader",
"stripedRowsDesc": "Skygge hver andre rad i vertslisten",
"rowDetailsTitle": "Radens detaljer", "rowDetailsTitle": "Radens detaljer",
"rowDetailsDesc": "Velg hva som vises for hver vert.", "rowDetailsDesc": "Velg hva som vises for hver vert.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Delt", "sharedBadge": "Delt",
"sharedBadgeTooltip": "Delt av {{owner}} ({{level}}-tilgang)", "sharedBadgeTooltip": "Delt av {{owner}} ({{level}}-tilgang)",
"viewOnlyBanner": "Denne verten er delt med deg av {{owner}} med visningstilgang. Konfigurasjonen er skrivebeskyttet.", "viewOnlyBanner": "Denne verten er delt med deg av {{owner}} med visningstilgang. Konfigurasjonen er skrivebeskyttet.",
"folderRuleRemoveFromHosts": "Fjern også denne tilgangen fra alle verter i mappen og undermapper",
"sharedCopyBanner": "Denne verten er delt med deg av {{owner}}. Skrivebordet holder en skrivebeskyttet kopi, så endre den på serveren og den synkroniseres hit.",
"sharedEditBanner": "Denne verten er delt med deg av {{owner}} med redigeringsrettigheter. Endringer gjelder den faktiske verten; autentiseringsreferanser kan kun endres av eieren.", "sharedEditBanner": "Denne verten er delt med deg av {{owner}} med redigeringsrettigheter. Endringer gjelder den faktiske verten; autentiseringsreferanser kan kun endres av eieren.",
"ownerOnlyControl": "Kun vertens eier kan endre dette", "ownerOnlyControl": "Kun vertens eier kan endre dette",
"ownerAuthPrivate": "Vertens eiers SSH-autentisering er privat. Bruk «Sett personlig SSH-autentisering» i vertsmenyen for å velge dine egne legitimasjon.", "ownerAuthPrivate": "Vertens eiers SSH-autentisering er privat. Bruk «Sett personlig SSH-autentisering» i vertsmenyen for å velge dine egne legitimasjon.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Zamknij pasek boczny po otwarciu hosta z jego listy", "closeOnOpenDesc": "Zamknij pasek boczny po otwarciu hosta z jego listy",
"showFolderPaths": "Pokaż ścieżki folderów", "showFolderPaths": "Pokaż ścieżki folderów",
"showFolderPathsDesc": "Pokaż ścieżkę nadrzędnego folderu nad nazwami zagnieżdżonych podfolderów. Podfoldery pozostają wcięte w obu przypadkach.", "showFolderPathsDesc": "Pokaż ścieżkę nadrzędnego folderu nad nazwami zagnieżdżonych podfolderów. Podfoldery pozostają wcięte w obu przypadkach.",
"stripedRows": "Naprzemienne kolory wierszy",
"stripedRowsDesc": "Zatieni co drugi wiersz w liście hostów",
"rowDetailsTitle": "Szczegóły wiersza", "rowDetailsTitle": "Szczegóły wiersza",
"rowDetailsDesc": "Wybierz, co ma być wyświetlane przy każdym hoście.", "rowDetailsDesc": "Wybierz, co ma być wyświetlane przy każdym hoście.",
"showAddress": "Adres", "showAddress": "Adres",
@@ -799,6 +801,8 @@
"sharedBadge": "Udostępniony", "sharedBadge": "Udostępniony",
"sharedBadgeTooltip": "Udostępniony przez {{owner}} (dostęp {{level}})", "sharedBadgeTooltip": "Udostępniony przez {{owner}} (dostęp {{level}})",
"viewOnlyBanner": "Ten host został udostępniony przez {{owner}} z dostępem do przeglądania. Konfiguracja jest tylko do odczytu.", "viewOnlyBanner": "Ten host został udostępniony przez {{owner}} z dostępem do przeglądania. Konfiguracja jest tylko do odczytu.",
"folderRuleRemoveFromHosts": "Usuń również ten dostęp z każdego hosta w tym folderze i jego podfolderach",
"sharedCopyBanner": "Ten host został udostępniony Ci przez {{owner}}. Pulpit przechowuje kopię tylko do odczytu, więc wprowadź zmiany na serwerze, a zsynchronizują się one tutaj.",
"sharedEditBanner": "Ten host został udostępniony przez {{owner}} z dostępem do edycji. Zmiany dotyczą rzeczywistego hosta; referencje uwierzytelniania mogą być zmieniane tylko przez właściciela.", "sharedEditBanner": "Ten host został udostępniony przez {{owner}} z dostępem do edycji. Zmiany dotyczą rzeczywistego hosta; referencje uwierzytelniania mogą być zmieniane tylko przez właściciela.",
"ownerOnlyControl": "Tylko właściciel hosta może to zmienić", "ownerOnlyControl": "Tylko właściciel hosta może to zmienić",
"ownerAuthPrivate": "Uwierzytelnianie SSH właściciela hosta jest prywatne. Użyj opcji „Ustaw własne uwierzytelnianie SSH” w menu hosta, aby wybrać własne dane logowania.", "ownerAuthPrivate": "Uwierzytelnianie SSH właściciela hosta jest prywatne. Użyj opcji „Ustaw własne uwierzytelnianie SSH” w menu hosta, aby wybrać własne dane logowania.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Fechar a barra lateral após abrir um host a partir dela", "closeOnOpenDesc": "Fechar a barra lateral após abrir um host a partir dela",
"showFolderPaths": "Mostrar caminhos das pastas", "showFolderPaths": "Mostrar caminhos das pastas",
"showFolderPathsDesc": "Mostra o caminho da pasta pai acima dos nomes de subpastas aninhadas. As subpastas permanecem indentadas de qualquer forma.", "showFolderPathsDesc": "Mostra o caminho da pasta pai acima dos nomes de subpastas aninhadas. As subpastas permanecem indentadas de qualquer forma.",
"stripedRows": "Cores alternadas nas linhas",
"stripedRowsDesc": "Sombrear linhas alternadas na lista de hosts",
"rowDetailsTitle": "Detalhes da linha", "rowDetailsTitle": "Detalhes da linha",
"rowDetailsDesc": "Escolha o que aparece em cada host.", "rowDetailsDesc": "Escolha o que aparece em cada host.",
"showAddress": "Endereço", "showAddress": "Endereço",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartilhado", "sharedBadge": "Compartilhado",
"sharedBadgeTooltip": "Compartilhado por {{owner}} (acesso {{level}})", "sharedBadgeTooltip": "Compartilhado por {{owner}} (acesso {{level}})",
"viewOnlyBanner": "Este host foi compartilhado com você por {{owner}} com acesso de visualização. A configuração é somente leitura.", "viewOnlyBanner": "Este host foi compartilhado com você por {{owner}} com acesso de visualização. A configuração é somente leitura.",
"folderRuleRemoveFromHosts": "Remover também esse acesso de todos os hosts na pasta e suas subpastas",
"sharedCopyBanner": "Este host foi compartilhado com você por {{owner}}. O desktop mantém uma cópia somente para leitura, então faça alterações no servidor e elas serão sincronizadas aqui.",
"sharedEditBanner": "Este host foi compartilhado com você por {{owner}} com acesso de edição. As alterações são aplicadas ao host real; as referências de autenticação só podem ser alteradas pelo proprietário.", "sharedEditBanner": "Este host foi compartilhado com você por {{owner}} com acesso de edição. As alterações são aplicadas 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", "ownerOnlyControl": "Somente o proprietário do host pode alterar isso",
"ownerAuthPrivate": "A autenticação SSH do proprietário do host é privada. Use “Definir autenticação SSH pessoal” no menu do host para escolher sua própria credencial.", "ownerAuthPrivate": "A autenticação SSH do proprietário do host é privada. Use “Definir autenticação SSH pessoal” no menu do host para escolher sua própria credencial.",
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more