mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Share a live terminal or remote desktop session by link or with another user, read-only or with input. Collaboration rooms let a presenter show sessions to a group.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Session Sharing
|
||||
|
||||
Share a live terminal or remote desktop session by link or with another user, and present sessions in collaboration rooms.
|
||||
|
||||
## Features
|
||||
|
||||
- Share a live terminal or remote desktop session by link or with one user.
|
||||
- Pick read-only or read and write, and set when the share expires.
|
||||
- Collaboration rooms where a presenter shows a session to a group and can hand control to a member.
|
||||
- Guest links for people without a Termix account.
|
||||
- The share button is in the terminal and remote desktop toolbars.
|
||||
|
||||
## Settings
|
||||
|
||||
### Admin
|
||||
|
||||
- Allow Session Sharing: turn sharing on or off for everyone.
|
||||
|
||||
### Host
|
||||
|
||||
- Allow Session Sharing: allow sharing sessions on this host.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `session-sharing.use`: Share sessions and join rooms. Admins and users have it by default.
|
||||
|
||||
## Services
|
||||
|
||||
Provides to other plugins:
|
||||
|
||||
- `sessions.sharing`: join a shared session or room from the terminal.
|
||||
|
||||
Uses from other plugins:
|
||||
|
||||
- `sessions.live` to reach live SSH, RDP, VNC and Telnet sessions.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Verbind met gedeelde sessie...",
|
||||
"linkInvalid": "Hierdie deelskakel is ongeldig, het verval of is herroep",
|
||||
"rateLimited": "Te veel pogings, probeer asseblief weer binnekort",
|
||||
"sessionEnded": "Hierdie sessie is verby",
|
||||
"readOnlyBadge": "Slegs kyk",
|
||||
"ownerLabel": "Gasheer-eienaar"
|
||||
},
|
||||
"modalTitle": "Deel sessie",
|
||||
"shareButton": "Deel",
|
||||
"notReadyToShare": "Sessie is nog nie gereed om te deel nie",
|
||||
"modeTab": {
|
||||
"link": "Skakel",
|
||||
"user": "Gebruiker"
|
||||
},
|
||||
"linkModeDescription": "Enigiemand met hierdie skakel kan aansluit, geen rekening nodig nie.",
|
||||
"userModeDescription": "Deel met 'n spesifieke gebruiker wat reeds toegang tot hierdie gasheer het. Indien hulle nog nie toegang het nie, deel die gasheer eers met hulle of gebruik eerder 'n skakel. Sodra dit gedeel is, verskyn die sessie in hul Verbindings-oortjie.",
|
||||
"permissionLevel": {
|
||||
"label": "Toestemmingsvlak",
|
||||
"readOnly": "Leesalleen",
|
||||
"readOnlyDescription": "Kan die sessie regstreeks kyk, maar kan nie tik of interaksie hê nie.",
|
||||
"readWrite": "Lees-skryf",
|
||||
"readWriteDescription": "Kan tik en met die sessie interaksie hê net soos die eienaar."
|
||||
},
|
||||
"expiryLabel": "Skakelvervaldatum",
|
||||
"createLinkButton": "Skep skakel",
|
||||
"createShareButton": "Deel met gebruiker",
|
||||
"searchUsersPlaceholder": "Soek gebruikers...",
|
||||
"noUsersFound": "Geen gebruikers gevind nie",
|
||||
"linkCreated": "Deel skakel geskep",
|
||||
"linkNeedsServer": "Deelskakels werk slegs vir sessies op 'n Termix-bediener. Koppel hierdie gasheer deur jou gekoppelde bediener om dit met 'n skakel te deel.",
|
||||
"linkCopied": "Skakel gekopieer na knipbord",
|
||||
"copyLink": "Kopieer skakel",
|
||||
"shareCreated": "Sessie gedeel. Dit sal in hul Verbindings-oortjie verskyn.",
|
||||
"shareFailed": "Kon nie deel skep nie",
|
||||
"userLacksHostAccess": "Daardie gebruiker het nog nie toegang tot hierdie gasheer nie. Deel die gasheer eers met hulle, of gebruik eerder 'n skakel.",
|
||||
"activeShares": "Aktiewe aandele",
|
||||
"noActiveShares": "Geen aktiewe delings vir hierdie sessie nie",
|
||||
"revoke": "Herroep",
|
||||
"revokeConfirmTitle": "Herroep hierdie deling?",
|
||||
"revokeConfirmDescription": "Enigiemand wat hierdie deel gebruik, sal onmiddellik toegang verloor.",
|
||||
"revoked": "Deel herroep",
|
||||
"revokeFailed": "Kon nie deel herroep nie",
|
||||
"joinCount": "{{count}} sluit aan",
|
||||
"joinCount_other": "{{count}} sluit aan",
|
||||
"expiresAt": "Vervaldatum {{date}}",
|
||||
"linkShareBadge": "Skakel",
|
||||
"userShareBadge": "Gebruiker: {{username}}",
|
||||
"loadSharesFailed": "Kon nie aktiewe delings laai nie",
|
||||
"remoteDisplayUnavailable": "Afstandswerkskerm is tans nie beskikbaar nie.",
|
||||
"sharedWithMe": "Sessies met my gedeel",
|
||||
"customHoursPlaceholder": "Ure totdat toegang verval"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nuwe kamer",
|
||||
"roomName": "Kamernaam",
|
||||
"persistentRoom": "Permanente kamer",
|
||||
"persistentRoomHint": "Permanente kamers bly gelys nadat die vergadering eindig en kan hergebruik word.",
|
||||
"noRooms": "Geen kamers nog nie. Skep een om 'n vergadering te begin.",
|
||||
"openRoom": "Oop",
|
||||
"endRoom": "Beëindig vergadering",
|
||||
"leaveRoom": "Vertrek",
|
||||
"deleteMember": "Verwyder",
|
||||
"removeMember": "Verwyder {{name}}",
|
||||
"removeMemberTitle": "Verwyder hierdie lid?",
|
||||
"removeMemberDescription": "{{name}} sal onmiddellik toegang tot hierdie kamer verloor. Enige aktiewe SSH-kyker sal ontkoppel word.",
|
||||
"invite": "Nooi uit",
|
||||
"inviteTitle": "Nooi lede uit",
|
||||
"members": "Lede",
|
||||
"membersPanel": "Kamerlede en beheerversoeke",
|
||||
"membersWithCount": "Lede ({{count}})",
|
||||
"guestsWithCount": "Gaste ({{count}})",
|
||||
"removeGuests": "Verwyder gaste",
|
||||
"anonymousGuest": "Gas",
|
||||
"online": "aanlyn",
|
||||
"offline": "vanlyn",
|
||||
"you": "(jy)",
|
||||
"hostBadge": "Gasheer",
|
||||
"presenterBadge": "Aanbieding",
|
||||
"present": "Aanwesig",
|
||||
"presentTitle": "Neem die verhoog",
|
||||
"presentHost": "Gasheer",
|
||||
"searchHosts": "Soek gashere",
|
||||
"noHostsFound": "Geen beskikbare gashere gevind nie.",
|
||||
"presentProtocol": "Protokol",
|
||||
"stopPresenting": "Hou op met aanbied",
|
||||
"takeOver": "Neem oor",
|
||||
"takeOverConfirmTitle": "Vervang die huidige aanbieding?",
|
||||
"takeOverDescription": "{{name}} bied nou aan. Oorname sal almal van daardie stadium ontkoppel.",
|
||||
"emptyStage": "Niemand bied aan nie. Neem die verhoog om 'n sessie te deel.",
|
||||
"stageLoading": "Verbind met die verhoog...",
|
||||
"stageEnded": "Die aanbieding het geëindig",
|
||||
"roomEnded": "Hierdie vergadering het geëindig",
|
||||
"presenterLabel": "{{name}} bied aan",
|
||||
"guestLink": "Gas skakel",
|
||||
"guestLinkOn": "Gasskakel is aan. Enigiemand met die skakel kan die verhoog kyk.",
|
||||
"guestLinkOff": "Gas skakel is af.",
|
||||
"copyLink": "Kopieer skakel",
|
||||
"rotateLink": "Roteer skakel",
|
||||
"disableLink": "Deaktiveer skakel",
|
||||
"rotateLinkConfirmTitle": "Draai die gasskakel?",
|
||||
"rotateLinkDescription": "Die huidige skakel sal onmiddellik ophou werk. Gekoppelde SSH-gaste sal ontkoppel word.",
|
||||
"disableLinkConfirmTitle": "Deaktiveer die gasskakel?",
|
||||
"disableLinkDescription": "Anonieme SSH-kykers sal onmiddellik ontkoppel word en die huidige skakel sal ophou werk.",
|
||||
"linkCopied": "Skakel gekopieer",
|
||||
"roles": "Rolle",
|
||||
"users": "Gebruikers",
|
||||
"searchUsers": "Soek gebruikers",
|
||||
"endConfirmTitle": "Beëindig hierdie vergadering?",
|
||||
"endDescription": "{{name}} sal vir almal eindig en kan nie weer heropen word nie.",
|
||||
"endPersistentDescription": "Die aktiewe aanbieding in {{name}} sal stop. Die kamer bly beskikbaar vir hergebruik.",
|
||||
"deleteRoom": "Vee kamer uit",
|
||||
"deleteConfirmTitle": "Vee hierdie kamer uit?",
|
||||
"deleteDescription": "{{name}} sal permanent vir almal uitgevee word en kan nie ongedaan gemaak word nie.",
|
||||
"invitedTo": "Jy is genooi na \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Vergadering",
|
||||
"waiting": "Wag vir die aanbieder...",
|
||||
"linkInvalid": "Hierdie gasskakel is ongeldig of die vergadering het geëindig"
|
||||
},
|
||||
"reopenFromPanel": "Hierdie vergadering-oortjie het verval. Maak die kamer weer oop vanaf die Vergaderings-paneel.",
|
||||
"retry": "Probeer weer",
|
||||
"requestControl": "Versoekbeheer",
|
||||
"cancelControlRequest": "Kanselleer versoek",
|
||||
"controlQueue": "Beheerversoeke",
|
||||
"noControlRequests": "Geen hangende versoeke nie.",
|
||||
"grantControlTo": "Gee beheer aan {{name}}",
|
||||
"dismissControlRequest": "Maak {{name}}se beheerversoek toe",
|
||||
"releaseControl": "Vrystellingsbeheer",
|
||||
"grantControl": "Gee beheer",
|
||||
"revokeControl": "Neem beheer terug",
|
||||
"controlRequestedBy": "{{name}} het om beheer gevra",
|
||||
"grant": "Toekenning",
|
||||
"controlBadge": "In beheer",
|
||||
"youArePresenting": "Jy bied aan in 'n ander oortjie. Stop die aanbieding om die verhoog oor te dra.",
|
||||
"created": "Kamer geskep",
|
||||
"invited": "Lede genooi",
|
||||
"roomTab": "Vergadering",
|
||||
"serverChanged": "Die vergaderingsbediener het verander. Maak hierdie vergadering weer oop vanaf die Vergaderingspaneel.",
|
||||
"hostNotSynced": "Sinkroniseer hierdie gasheer met die vergaderingbediener voor die aanbieding.",
|
||||
"linkRequiresServer": "Koppel hierdie lessenaar aan 'n Termix-bediener en skep die vergadering daar om 'n webskakel te deel."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Vergaderings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Laat Sessiedeling Toe",
|
||||
"description": "Laat lewendige terminaal-, RDP-, VNC- en Telnet-sessies toe om instansiewyd gedeel te word. Oorskryf elke deelskakelaar per gasheer wanneer dit gedeaktiveer is."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Laat Sessiedeling Toe",
|
||||
"description": "Laat regstreekse sessies op hierdie gasheer via skakel of met ander gebruikers gedeel word"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Deel sessies en sluit aan by kamers",
|
||||
"description": "Deel regstreekse sessies per skakel of met ander gebruikers, en skep en sluit aan by samewerkingskamers."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nooit",
|
||||
"oneHour": "1 uur",
|
||||
"oneDay": "24 uur",
|
||||
"sevenDays": "7 dae",
|
||||
"thirtyDays": "30 dae",
|
||||
"custom": "Pasgemaak"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Met my gedeel",
|
||||
"sharedBy": "Gedeel deur {{username}}",
|
||||
"join": "Sluit aan",
|
||||
"sharedSessionLabel": "{{hostName}} (gedeel)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "جارٍ الاتصال بالجلسة المشتركة...",
|
||||
"linkInvalid": "رابط المشاركة هذا غير صالح أو منتهي الصلاحية أو تم إلغاؤه",
|
||||
"rateLimited": "عدد المحاولات كبير جدًا، يرجى المحاولة مرة أخرى قريبًا",
|
||||
"sessionEnded": "انتهت هذه الجلسة",
|
||||
"readOnlyBadge": "للعرض فقط",
|
||||
"ownerLabel": "مالك المضيف"
|
||||
},
|
||||
"modalTitle": "جلسة مشاركة",
|
||||
"shareButton": "مشاركة",
|
||||
"notReadyToShare": "الجلسة غير جاهزة للمشاركة بعد",
|
||||
"modeTab": {
|
||||
"link": "وصلة",
|
||||
"user": "المستخدم"
|
||||
},
|
||||
"linkModeDescription": "يمكن لأي شخص لديه هذا الرابط الانضمام، ولا يلزم وجود حساب.",
|
||||
"userModeDescription": "شارك الملف مع مستخدم محدد لديه صلاحية الوصول إلى هذا المضيف. إذا لم يكن لديه صلاحية الوصول بعد، فشارك معه المضيف أولاً أو استخدم رابطًا. بمجرد المشاركة، ستظهر الجلسة في علامة تبويب \"الاتصالات\" الخاصة به.",
|
||||
"permissionLevel": {
|
||||
"label": "مستوى الصلاحية",
|
||||
"readOnly": "للقراءة فقط",
|
||||
"readOnlyDescription": "يمكنني مشاهدة الجلسة مباشرة ولكن لا يمكنني الكتابة أو التفاعل.",
|
||||
"readWrite": "القراءة والكتابة",
|
||||
"readWriteDescription": "يمكنه الكتابة والتفاعل مع الجلسة تمامًا مثل المالك."
|
||||
},
|
||||
"expiryLabel": "انتهاء صلاحية الرابط",
|
||||
"createLinkButton": "إنشاء رابط",
|
||||
"createShareButton": "مشاركة مع المستخدم",
|
||||
"searchUsersPlaceholder": "ابحث عن المستخدمين...",
|
||||
"noUsersFound": "لم يتم العثور على مستخدمين",
|
||||
"linkCreated": "تم إنشاء رابط المشاركة",
|
||||
"linkNeedsServer": "لا تعمل روابط المشاركة إلا مع الجلسات الموجودة على خادم Termix. قم بتوصيل هذا المضيف عبر خادمك المرتبط لمشاركته باستخدام رابط.",
|
||||
"linkCopied": "تم نسخ الرابط إلى الحافظة",
|
||||
"copyLink": "نسخ الوصلة",
|
||||
"shareCreated": "تمت مشاركة الجلسة. ستظهر في علامة تبويب \"الاتصالات\" الخاصة بهم.",
|
||||
"shareFailed": "فشل إنشاء المشاركة",
|
||||
"userLacksHostAccess": "لا يملك هذا المستخدم صلاحية الوصول إلى هذا المضيف بعد. شارك المضيف معه أولاً، أو استخدم رابطاً بدلاً من ذلك.",
|
||||
"activeShares": "الأسهم النشطة",
|
||||
"noActiveShares": "لا توجد أسهم نشطة لهذه الجلسة",
|
||||
"revoke": "إبطال",
|
||||
"revokeConfirmTitle": "إلغاء هذه الحصة؟",
|
||||
"revokeConfirmDescription": "أي شخص يستخدم هذا الملف المشترك سيفقد الوصول إليه فوراً.",
|
||||
"revoked": "تم إلغاء الحصة",
|
||||
"revokeFailed": "فشل في إلغاء المشاركة",
|
||||
"joinCount": "{{count}} انضم",
|
||||
"joinCount_other": "{{count}} ينضم",
|
||||
"expiresAt": "تنتهي في {{date}}",
|
||||
"linkShareBadge": "وصلة",
|
||||
"userShareBadge": "المستخدم: {{username}}",
|
||||
"loadSharesFailed": "فشل تحميل المشاركات النشطة",
|
||||
"remoteDisplayUnavailable": "خدمة سطح المكتب البعيد غير متاحة حاليًا.",
|
||||
"sharedWithMe": "الجلسات التي تمت مشاركتها معي",
|
||||
"customHoursPlaceholder": "عدد الساعات حتى انتهاء صلاحية الوصول"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "غرف جديدة",
|
||||
"roomName": "اسم الغرفة",
|
||||
"persistentRoom": "غرفة مستمرة",
|
||||
"persistentRoomHint": "تبقى الغرف الدائمة مدرجة بعد انتهاء الاجتماع ويمكن إعادة استخدامها.",
|
||||
"noRooms": "لا توجد غرف اجتماعات بعد. أنشئ غرفة لبدء اجتماع.",
|
||||
"openRoom": "فتح",
|
||||
"endRoom": "انتهاء الاجتماع",
|
||||
"leaveRoom": "يترك",
|
||||
"deleteMember": "إزالة",
|
||||
"removeMember": "إزالة {{name}}",
|
||||
"removeMemberTitle": "هل تريد حذف هذا العضو؟",
|
||||
"removeMemberDescription": "سيفقد المستخدم {{name}} إمكانية الوصول إلى هذه الغرفة فورًا. وسيتم فصل أي برنامج عرض SSH نشط.",
|
||||
"invite": "يدعو",
|
||||
"inviteTitle": "دعوة الأعضاء",
|
||||
"members": "أعضاء",
|
||||
"membersPanel": "أعضاء الغرفة وطلبات التحكم",
|
||||
"membersWithCount": "الأعضاء ({{count}})",
|
||||
"guestsWithCount": "الضيوف ({{count}})",
|
||||
"removeGuests": "إزالة الضيوف",
|
||||
"anonymousGuest": "ضيف",
|
||||
"online": "متصل",
|
||||
"offline": "غير متصل",
|
||||
"you": "(أنت)",
|
||||
"hostBadge": "المضيف",
|
||||
"presenterBadge": "تقديم",
|
||||
"present": "حاضر",
|
||||
"presentTitle": "اصعد إلى المسرح",
|
||||
"presentHost": "المضيف",
|
||||
"searchHosts": "ابحث عن مضيفين",
|
||||
"noHostsFound": "لم يتم العثور على مضيفين متاحين.",
|
||||
"presentProtocol": "البروتوكول",
|
||||
"stopPresenting": "توقف عن التقديم",
|
||||
"takeOver": "تولي زمام الأمور",
|
||||
"takeOverConfirmTitle": "هل تريد استبدال العرض التقديمي الحالي؟",
|
||||
"takeOverDescription": "{{name}} يقدم العرض الآن. سيؤدي الاستحواذ على المنصة إلى فصل الجميع عنها.",
|
||||
"emptyStage": "لا يوجد مقدمون. تفضلوا بالصعود إلى المنصة لمشاركة جلسة.",
|
||||
"stageLoading": "الاتصال بالمسرح...",
|
||||
"stageEnded": "انتهى العرض التقديمي",
|
||||
"roomEnded": "انتهى هذا الاجتماع",
|
||||
"presenterLabel": "{{name}} يقدم",
|
||||
"guestLink": "رابط الضيف",
|
||||
"guestLinkOn": "رابط الضيوف مُفعّل. يمكن لأي شخص لديه الرابط مشاهدة العرض.",
|
||||
"guestLinkOff": "رابط الضيف معطل.",
|
||||
"copyLink": "نسخ الوصلة",
|
||||
"rotateLink": "تدوير الرابط",
|
||||
"disableLink": "تعطيل الرابط",
|
||||
"rotateLinkConfirmTitle": "هل تريد تدوير رابط الضيف؟",
|
||||
"rotateLinkDescription": "سيتوقف الرابط الحالي عن العمل فوراً. سيتم فصل الضيوف المتصلين عبر SSH.",
|
||||
"disableLinkConfirmTitle": "هل تريد تعطيل رابط الضيف؟",
|
||||
"disableLinkDescription": "سيتم قطع اتصال عارضي SSH المجهولين على الفور وسيتوقف الرابط الحالي عن العمل.",
|
||||
"linkCopied": "تم نسخ الرابط",
|
||||
"roles": "الأدوار",
|
||||
"users": "المستخدمون",
|
||||
"searchUsers": "ابحث عن المستخدمين",
|
||||
"endConfirmTitle": "هل ننهي هذا الاجتماع؟",
|
||||
"endDescription": "سينتهي {{name}} للجميع ولن يكون من الممكن إعادة فتحه.",
|
||||
"endPersistentDescription": "سيتوقف العرض التقديمي النشط في {{name}} . وستبقى الغرفة متاحة لإعادة الاستخدام.",
|
||||
"deleteRoom": "حذف الغرفة",
|
||||
"deleteConfirmTitle": "هل تريد حذف هذه الغرفة؟",
|
||||
"deleteDescription": "سيتم حذف {{name}} نهائياً للجميع ولا يمكن التراجع عنه.",
|
||||
"invitedTo": "لقد تمت دعوتك إلى \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "مقابلة",
|
||||
"waiting": "في انتظار المذيع...",
|
||||
"linkInvalid": "رابط الضيف هذا غير صالح أو انتهى الاجتماع"
|
||||
},
|
||||
"reopenFromPanel": "انتهت صلاحية علامة تبويب هذا الاجتماع. أعد فتح الغرفة من لوحة الاجتماعات.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"requestControl": "التحكم في الطلبات",
|
||||
"cancelControlRequest": "إلغاء الطلب",
|
||||
"controlQueue": "طلبات التحكم",
|
||||
"noControlRequests": "لا توجد طلبات معلقة.",
|
||||
"grantControlTo": "قم بمنح التحكم إلى {{name}}",
|
||||
"dismissControlRequest": "رفض طلب التحكم {{name}}",
|
||||
"releaseControl": "التحكم في الإصدار",
|
||||
"grantControl": "امنح السيطرة",
|
||||
"revokeControl": "استعد السيطرة",
|
||||
"controlRequestedBy": "طلب {{name}} التحكم",
|
||||
"grant": "منحة",
|
||||
"controlBadge": "مسيطر",
|
||||
"youArePresenting": "أنت تعرض المحتوى في علامة تبويب أخرى. توقف عن العرض لتسليم المنصة.",
|
||||
"created": "تم إنشاء الغرفة",
|
||||
"invited": "الأعضاء المدعوون",
|
||||
"roomTab": "مقابلة",
|
||||
"serverChanged": "تم تغيير خادم الاجتماع. أعد فتح هذا الاجتماع من لوحة الاجتماعات.",
|
||||
"hostNotSynced": "قم بمزامنة هذا المضيف مع خادم الاجتماع قبل العرض التقديمي.",
|
||||
"linkRequiresServer": "قم بربط هذا الجهاز بخادم Termix وقم بإنشاء الاجتماع هناك لمشاركة رابط الويب."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "الاجتماعات"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "السماح بمشاركة الجلسة",
|
||||
"description": "يسمح هذا الخيار بمشاركة جلسات الطرفية المباشرة، وجلسات RDP، وVNC، وTelnet على مستوى الجهاز. ويتجاوز هذا الخيار جميع إعدادات المشاركة الخاصة بكل مضيف عند تعطيله."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "السماح بمشاركة الجلسة",
|
||||
"description": "يمكن مشاركة الجلسات المباشرة على هذا المضيف عبر رابط أو مع مستخدمين آخرين"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "شارك الجلسات وانضم إلى الغرف",
|
||||
"description": "شارك الجلسات المباشرة عبر رابط أو مع مستخدمين آخرين، وقم بإنشاء غرف تعاون والانضمام إليها."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "أبدًا",
|
||||
"oneHour": "ساعة واحدة",
|
||||
"oneDay": "24 ساعة",
|
||||
"sevenDays": "7 أيام",
|
||||
"thirtyDays": "30 يومًا",
|
||||
"custom": "مخصص"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "تمت مشاركته معي",
|
||||
"sharedBy": "تمت المشاركة بواسطة {{username}}",
|
||||
"join": "ينضم",
|
||||
"sharedSessionLabel": "{{hostName}} (مشترك)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Свързване със споделена сесия...",
|
||||
"linkInvalid": "Този линк за споделяне е невалиден, изтекъл или е отменен",
|
||||
"rateLimited": "Твърде много опити, моля опитайте отново скоро",
|
||||
"sessionEnded": "Тази сесия приключи",
|
||||
"readOnlyBadge": "Само преглед",
|
||||
"ownerLabel": "Собственик на домакин"
|
||||
},
|
||||
"modalTitle": "Споделяне на сесия",
|
||||
"shareButton": "Споделяне",
|
||||
"notReadyToShare": "Сесията все още не е готова за споделяне",
|
||||
"modeTab": {
|
||||
"link": "Връзка",
|
||||
"user": "Потребител"
|
||||
},
|
||||
"linkModeDescription": "Всеки с този линк може да се присъедини, не е необходим акаунт.",
|
||||
"userModeDescription": "Споделете с конкретен потребител, който вече има достъп до този хост. Ако все още няма достъп, първо споделете хоста с него или използвайте връзка. След като бъде споделена, сесията се показва в раздела му „Връзки“.",
|
||||
"permissionLevel": {
|
||||
"label": "Ниво на разрешение",
|
||||
"readOnly": "Само за четене",
|
||||
"readOnlyDescription": "Може да гледа сесията на живо, но не може да пише или да взаимодейства.",
|
||||
"readWrite": "Четене и запис",
|
||||
"readWriteDescription": "Може да пише и да взаимодейства със сесията точно като собственика."
|
||||
},
|
||||
"expiryLabel": "Изтичане на срока на валидност на връзката",
|
||||
"createLinkButton": "Създаване на връзка",
|
||||
"createShareButton": "Споделяне с потребителя",
|
||||
"searchUsersPlaceholder": "Търсене на потребители...",
|
||||
"noUsersFound": "Не са намерени потребители",
|
||||
"linkCreated": "Линкът за споделяне е създаден",
|
||||
"linkNeedsServer": "Линковете за споделяне работят само за сесии на Termix сървър. Свържете този хост чрез свързания си сървър, за да го споделите с линк.",
|
||||
"linkCopied": "Връзката е копирана в буферната памет",
|
||||
"copyLink": "Копиране на връзката",
|
||||
"shareCreated": "Сесията е споделена. Тя ще се покаже в раздела им „Връзки“.",
|
||||
"shareFailed": "Създаването на споделено съдържание не бе успешно",
|
||||
"userLacksHostAccess": "Тозият потребител все още няма достъп до този хост. Първо споделете хоста с него или използвайте линк.",
|
||||
"activeShares": "Активни акции",
|
||||
"noActiveShares": "Няма активни споделяния за тази сесия",
|
||||
"revoke": "Отмяна",
|
||||
"revokeConfirmTitle": "Да се отмени ли това споделяне?",
|
||||
"revokeConfirmDescription": "Всеки, който използва това споделяне, ще загуби достъп незабавно.",
|
||||
"revoked": "Акцията е отменена",
|
||||
"revokeFailed": "Неуспешно отменяне на споделянето",
|
||||
"joinCount": "{{count}} присъединяване",
|
||||
"joinCount_other": "{{count}} се присъединява",
|
||||
"expiresAt": "Изтича {{date}}",
|
||||
"linkShareBadge": "Връзка",
|
||||
"userShareBadge": "Потребител: {{username}}",
|
||||
"loadSharesFailed": "Зареждането на активните споделяния не бе успешно",
|
||||
"remoteDisplayUnavailable": "Отдалеченият работен плот не е наличен в момента.",
|
||||
"sharedWithMe": "Сесии, споделени с мен",
|
||||
"customHoursPlaceholder": "Часове до изтичане на срока на достъп"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Нова стая",
|
||||
"roomName": "Име на стаята",
|
||||
"persistentRoom": "Постоянна стая",
|
||||
"persistentRoomHint": "Постоянните стаи остават в списъка след края на срещата и могат да бъдат използвани повторно.",
|
||||
"noRooms": "Все още няма стаи. Създайте една, за да започнете среща.",
|
||||
"openRoom": "Отворено",
|
||||
"endRoom": "Край на срещата",
|
||||
"leaveRoom": "Оставете",
|
||||
"deleteMember": "Премахване",
|
||||
"removeMember": "Премахване на {{name}}",
|
||||
"removeMemberTitle": "Да се премахне ли този член?",
|
||||
"removeMemberDescription": "{{name}} ще загуби достъп до тази стая незабавно. Всеки активен SSH зрител ще бъде прекъснат.",
|
||||
"invite": "Покани",
|
||||
"inviteTitle": "Поканете членове",
|
||||
"members": "Членове",
|
||||
"membersPanel": "Членове на стаята и заявки за контрол",
|
||||
"membersWithCount": "Членове ({{count}})",
|
||||
"guestsWithCount": "Гости ({{count}})",
|
||||
"removeGuests": "Премахване на гости",
|
||||
"anonymousGuest": "Гост",
|
||||
"online": "онлайн",
|
||||
"offline": "офлайн",
|
||||
"you": "(ти)",
|
||||
"hostBadge": "Домакин",
|
||||
"presenterBadge": "Представяне",
|
||||
"present": "Настояще",
|
||||
"presentTitle": "Излезте на сцената",
|
||||
"presentHost": "Домакин",
|
||||
"searchHosts": "Търсене на хостове",
|
||||
"noHostsFound": "Няма намерени налични хостове.",
|
||||
"presentProtocol": "Протокол",
|
||||
"stopPresenting": "Спиране на представянето",
|
||||
"takeOver": "Поемете контрола",
|
||||
"takeOverConfirmTitle": "Да се замени ли текущата презентация?",
|
||||
"takeOverDescription": "{{name}} представя сега. Поемането на водещия ще изключи всички от тази сцена.",
|
||||
"emptyStage": "Никой не представя. Излезте на сцената, за да споделите сесия.",
|
||||
"stageLoading": "Свързване със сцената...",
|
||||
"stageEnded": "Презентацията приключи",
|
||||
"roomEnded": "Тази среща приключи",
|
||||
"presenterLabel": "{{name}} представя",
|
||||
"guestLink": "Връзка за гости",
|
||||
"guestLinkOn": "Връзката за гости е включена. Всеки с връзката може да гледа сцената.",
|
||||
"guestLinkOff": "Връзката за гости е изключена.",
|
||||
"copyLink": "Копиране на връзката",
|
||||
"rotateLink": "Завъртане на връзката",
|
||||
"disableLink": "Деактивиране на връзката",
|
||||
"rotateLinkConfirmTitle": "Да се завърти ли линкът за гости?",
|
||||
"rotateLinkDescription": "Текущата връзка ще спре да работи незабавно. Свързаните SSH гости ще бъдат прекъснати.",
|
||||
"disableLinkConfirmTitle": "Да се деактивира ли връзката за гости?",
|
||||
"disableLinkDescription": "Анонимните SSH зрители ще бъдат незабавно прекъснати и текущата връзка ще спре да работи.",
|
||||
"linkCopied": "Връзката е копирана",
|
||||
"roles": "Роли",
|
||||
"users": "Потребители",
|
||||
"searchUsers": "Търсене на потребители",
|
||||
"endConfirmTitle": "Да се прекрати ли тази среща?",
|
||||
"endDescription": "{{name}} ще приключи за всички и не може да бъде отворено отново.",
|
||||
"endPersistentDescription": "Активната презентация в {{name}} ще спре. Стаята остава достъпна за повторна употреба.",
|
||||
"deleteRoom": "Изтриване на стая",
|
||||
"deleteConfirmTitle": "Да се изтрие ли тази стая?",
|
||||
"deleteDescription": "{{name}} ще бъде изтрито за постоянно за всички и действието не може да бъде отменено.",
|
||||
"invitedTo": "Поканиха ви в „{{name}}“",
|
||||
"guest": {
|
||||
"title": "Среща",
|
||||
"waiting": "В очакване на водещия...",
|
||||
"linkInvalid": "Този линк за гости е невалиден или срещата е приключила"
|
||||
},
|
||||
"reopenFromPanel": "Този раздел за среща е с изтекъл срок. Отворете отново стаята от панела „Срещи“.",
|
||||
"retry": "Опитай отново",
|
||||
"requestControl": "Заявка за контрол",
|
||||
"cancelControlRequest": "Отмяна на заявката",
|
||||
"controlQueue": "Заявки за контрол",
|
||||
"noControlRequests": "Няма чакащи заявки.",
|
||||
"grantControlTo": "Дайте контрол на {{name}}",
|
||||
"dismissControlRequest": "Отхвърляне на заявката за контрол на {{name}}",
|
||||
"releaseControl": "Контрол на освобождаването",
|
||||
"grantControl": "Дайте контрол",
|
||||
"revokeControl": "Върнете си контрола",
|
||||
"controlRequestedBy": "{{name}} поиска контрол",
|
||||
"grant": "Грант",
|
||||
"controlBadge": "В контрол",
|
||||
"youArePresenting": "Презентирате в друг раздел. Спрете презентацията, за да предадете сцената.",
|
||||
"created": "Стаята е създадена",
|
||||
"invited": "Поканени членове",
|
||||
"roomTab": "Среща",
|
||||
"serverChanged": "Сървърът за срещата се промени. Отворете отново тази среща от панела „Срещи“.",
|
||||
"hostNotSynced": "Синхронизирайте този хост със сървъра за срещи преди представяне.",
|
||||
"linkRequiresServer": "Свържете този работен плот със Termix сървър и създайте срещата там, за да споделите уеб връзка."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Срещи"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Разрешаване на споделяне на сесия",
|
||||
"description": "Разрешава споделянето на сесии на живо на терминал, RDP, VNC и Telnet в целия екземпляр. Заменя всяко превключвател за споделяне на хост, когато е деактивиран."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Разрешаване на споделяне на сесия",
|
||||
"description": "Позволете споделянето на сесиите на живо на този хост чрез линк или с други потребители"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Споделяне на сесии и присъединяване към стаи",
|
||||
"description": "Споделяйте сесии на живо чрез линк или с други потребители, както и създавайте и се присъединявайте към стаи за сътрудничество."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Никога",
|
||||
"oneHour": "1 час",
|
||||
"oneDay": "24 часа",
|
||||
"sevenDays": "7 дни",
|
||||
"thirtyDays": "30 дни",
|
||||
"custom": "Персонализирано"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Споделено с мен",
|
||||
"sharedBy": "Споделено от {{username}}",
|
||||
"join": "Присъединете се",
|
||||
"sharedSessionLabel": "{{hostName}} (споделено)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connectant a la sessió compartida...",
|
||||
"linkInvalid": "Aquest enllaç per compartir no és vàlid, ha caducat o s'ha revocat.",
|
||||
"rateLimited": "Massa intents, si us plau, torneu-ho a intentar en breu",
|
||||
"sessionEnded": "Aquesta sessió ha finalitzat",
|
||||
"readOnlyBadge": "Només visualització",
|
||||
"ownerLabel": "Propietari de l'amfitrió"
|
||||
},
|
||||
"modalTitle": "Comparteix la sessió",
|
||||
"shareButton": "Comparteix",
|
||||
"notReadyToShare": "La sessió encara no està a punt per compartir",
|
||||
"modeTab": {
|
||||
"link": "Enllaç",
|
||||
"user": "Usuari"
|
||||
},
|
||||
"linkModeDescription": "Qualsevol persona amb aquest enllaç s'hi pot unir, no cal tenir cap compte.",
|
||||
"userModeDescription": "Comparteix amb un usuari específic que ja tingui accés a aquest amfitrió. Si encara no hi té accés, comparteix primer l'amfitrió amb ell o utilitza un enllaç. Un cop compartida, la sessió apareix a la pestanya Connexions.",
|
||||
"permissionLevel": {
|
||||
"label": "Nivell de permís",
|
||||
"readOnly": "Només lectura",
|
||||
"readOnlyDescription": "Puc veure la sessió en directe però no puc escriure ni interactuar.",
|
||||
"readWrite": "Lectura i escriptura",
|
||||
"readWriteDescription": "Pot escriure i interactuar amb la sessió igual que el propietari."
|
||||
},
|
||||
"expiryLabel": "Caducitat de l'enllaç",
|
||||
"createLinkButton": "Crea un enllaç",
|
||||
"createShareButton": "Comparteix amb l'usuari",
|
||||
"searchUsersPlaceholder": "Cerca usuaris...",
|
||||
"noUsersFound": "No s'han trobat usuaris",
|
||||
"linkCreated": "Enllaç de compartició creat",
|
||||
"linkNeedsServer": "Els enllaços per compartir només funcionen per a sessions en un servidor Termix. Connecteu aquest amfitrió a través del vostre servidor enllaçat per compartir-lo amb un enllaç.",
|
||||
"linkCopied": "Enllaç copiat al porta-retalls",
|
||||
"copyLink": "Copia l'enllaç",
|
||||
"shareCreated": "Sessió compartida. Apareixerà a la pestanya Connexions.",
|
||||
"shareFailed": "No s'ha pogut crear la compartició",
|
||||
"userLacksHostAccess": "Aquest usuari encara no té accés a aquest amfitrió. Primer comparteix l'amfitrió amb ell o utilitza un enllaç.",
|
||||
"activeShares": "Accions actives",
|
||||
"noActiveShares": "No hi ha cap compartició activa per a aquesta sessió",
|
||||
"revoke": "Revocar",
|
||||
"revokeConfirmTitle": "Voleu revocar aquesta compartició?",
|
||||
"revokeConfirmDescription": "Qualsevol persona que utilitzi aquesta opció compartida perdrà l'accés immediatament.",
|
||||
"revoked": "Compartir revocat",
|
||||
"revokeFailed": "No s'ha pogut revocar la compartició",
|
||||
"joinCount": "{{count}} uneix-te",
|
||||
"joinCount_other": "{{count}} s'uneix",
|
||||
"expiresAt": "Caduca {{date}}",
|
||||
"linkShareBadge": "Enllaç",
|
||||
"userShareBadge": "Usuari: {{username}}",
|
||||
"loadSharesFailed": "No s'han pogut carregar les comparticions actives",
|
||||
"remoteDisplayUnavailable": "L'escriptori remot no està disponible ara mateix.",
|
||||
"sharedWithMe": "Sessions compartides amb mi",
|
||||
"customHoursPlaceholder": "Hores fins que caduca l'accés"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nova habitació",
|
||||
"roomName": "Nom de l'habitació",
|
||||
"persistentRoom": "Sala persistent",
|
||||
"persistentRoomHint": "Les sales persistents romanen a la llista després que finalitzi la reunió i es poden reutilitzar.",
|
||||
"noRooms": "Encara no hi ha cap sala. Crea'n una per iniciar una reunió.",
|
||||
"openRoom": "Obre",
|
||||
"endRoom": "Finalitza la reunió",
|
||||
"leaveRoom": "Marxar",
|
||||
"deleteMember": "Elimina",
|
||||
"removeMember": "Elimina {{name}}",
|
||||
"removeMemberTitle": "Voleu eliminar aquest membre?",
|
||||
"removeMemberDescription": "{{name}} perdrà l'accés a aquesta sala immediatament. Qualsevol visor SSH actiu es desconnectarà.",
|
||||
"invite": "Convidar",
|
||||
"inviteTitle": "Convida membres",
|
||||
"members": "Membres",
|
||||
"membersPanel": "Membres de la sala i sol·licituds de control",
|
||||
"membersWithCount": "Membres ({{count}})",
|
||||
"guestsWithCount": "Convidats ({{count}})",
|
||||
"removeGuests": "Elimina els convidats",
|
||||
"anonymousGuest": "Convidat",
|
||||
"online": "en línia",
|
||||
"offline": "fora de línia",
|
||||
"you": "(tu)",
|
||||
"hostBadge": "Amfitrió",
|
||||
"presenterBadge": "Presentant",
|
||||
"present": "Present",
|
||||
"presentTitle": "Puja a l'escenari",
|
||||
"presentHost": "Amfitrió",
|
||||
"searchHosts": "Cerca amfitrions",
|
||||
"noHostsFound": "No s'han trobat amfitrions disponibles.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Atura la presentació",
|
||||
"takeOver": "Prendre el control",
|
||||
"takeOverConfirmTitle": "Voleu substituir la presentació actual?",
|
||||
"takeOverDescription": "{{name}} està presentant ara. Prendre el control desconnectarà tothom d'aquesta etapa.",
|
||||
"emptyStage": "Ningú fa una presentació. Puja a l'escenari per compartir una sessió.",
|
||||
"stageLoading": "Connexió amb l'escenari...",
|
||||
"stageEnded": "La presentació va acabar",
|
||||
"roomEnded": "Aquesta reunió ha acabat",
|
||||
"presenterLabel": "{{name}} presenta",
|
||||
"guestLink": "Enllaç de convidat",
|
||||
"guestLinkOn": "L'enllaç de convidat està activat. Qualsevol persona que tingui l'enllaç pot veure l'escenari.",
|
||||
"guestLinkOff": "L'enllaç de convidat està desactivat.",
|
||||
"copyLink": "Copia l'enllaç",
|
||||
"rotateLink": "Gira l'enllaç",
|
||||
"disableLink": "Desactiva l'enllaç",
|
||||
"rotateLinkConfirmTitle": "Voleu girar l'enllaç de convidat?",
|
||||
"rotateLinkDescription": "L'enllaç actual deixarà de funcionar immediatament. Els convidats SSH connectats es desconnectaran.",
|
||||
"disableLinkConfirmTitle": "Voleu desactivar l'enllaç de convidat?",
|
||||
"disableLinkDescription": "Els visors SSH anònims es desconnectaran immediatament i l'enllaç actual deixarà de funcionar.",
|
||||
"linkCopied": "Enllaç copiat",
|
||||
"roles": "Rols",
|
||||
"users": "Usuaris",
|
||||
"searchUsers": "Cerca usuaris",
|
||||
"endConfirmTitle": "Voleu finalitzar aquesta reunió?",
|
||||
"endDescription": "{{name}} finalitzarà per a tothom i no es podrà tornar a obrir.",
|
||||
"endPersistentDescription": "La presentació activa a {{name}} s'aturarà. La sala romandrà disponible per a reutilització.",
|
||||
"deleteRoom": "Suprimeix l'habitació",
|
||||
"deleteConfirmTitle": "Voleu suprimir aquesta sala?",
|
||||
"deleteDescription": "{{name}} s'eliminarà permanentment per a tothom i no es podrà desfer.",
|
||||
"invitedTo": "Has estat convidat a \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Reunió",
|
||||
"waiting": "Esperant el presentador...",
|
||||
"linkInvalid": "Aquest enllaç de convidat no és vàlid o la reunió ha finalitzat."
|
||||
},
|
||||
"reopenFromPanel": "Aquesta pestanya de reunió ha caducat. Torna a obrir la sala des del tauler Reunions.",
|
||||
"retry": "Torna-ho a intentar",
|
||||
"requestControl": "Sol·licitud de control",
|
||||
"cancelControlRequest": "Cancel·la la sol·licitud",
|
||||
"controlQueue": "Sol·licituds de control",
|
||||
"noControlRequests": "No hi ha sol·licituds pendents.",
|
||||
"grantControlTo": "Dóna el control a {{name}}",
|
||||
"dismissControlRequest": "Ignora la sol·licitud de control de {{name}}",
|
||||
"releaseControl": "Control de llançament",
|
||||
"grantControl": "Donar control",
|
||||
"revokeControl": "Recupera el control",
|
||||
"controlRequestedBy": "{{name}} va demanar control",
|
||||
"grant": "Subvenció",
|
||||
"controlBadge": "En control",
|
||||
"youArePresenting": "Esteu presentant en una altra pestanya. Deixeu de presentar per cedir l'escenari.",
|
||||
"created": "Sala creada",
|
||||
"invited": "Membres convidats",
|
||||
"roomTab": "Reunió",
|
||||
"serverChanged": "El servidor de reunions ha canviat. Torna a obrir aquesta reunió des del tauler Reunions.",
|
||||
"hostNotSynced": "Sincronitza aquest amfitrió amb el servidor de reunions abans de presentar.",
|
||||
"linkRequiresServer": "Enllaça aquest escriptori a un servidor de Termix i crea-hi la reunió per compartir un enllaç web."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Reunions"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Permetre la compartició de sessions",
|
||||
"description": "Permet que les sessions de terminal, RDP, VNC i Telnet en directe es comparteixin a tota la instància. Anul·la tots els activables i desactivables per compartir per host quan estan desactivats."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Permetre la compartició de sessions",
|
||||
"description": "Permet que les sessions en directe d'aquest amfitrió es comparteixin mitjançant un enllaç o amb altres usuaris"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Comparteix sessions i uneix-te a sales",
|
||||
"description": "Comparteix sessions en directe mitjançant un enllaç o amb altres usuaris, i crea sales de col·laboració i uneix-te-hi."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Mai",
|
||||
"oneHour": "1 hora",
|
||||
"oneDay": "24 hores",
|
||||
"sevenDays": "7 dies",
|
||||
"thirtyDays": "30 dies",
|
||||
"custom": "Personalitzat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Compartit amb mi",
|
||||
"sharedBy": "Compartit per {{username}}",
|
||||
"join": "Uneix-te",
|
||||
"sharedSessionLabel": "{{hostName}} (compartit)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Připojování ke sdílené relaci...",
|
||||
"linkInvalid": "Tento odkaz pro sdílení je neplatný, jeho platnost vypršela nebo byl zrušen.",
|
||||
"rateLimited": "Příliš mnoho pokusů, zkuste to prosím znovu brzy",
|
||||
"sessionEnded": "Tato relace skončila",
|
||||
"readOnlyBadge": "Pouze zobrazení",
|
||||
"ownerLabel": "Majitel hostitele"
|
||||
},
|
||||
"modalTitle": "Sdílení relace",
|
||||
"shareButton": "Podíl",
|
||||
"notReadyToShare": "Relace ještě není připravena ke sdílení",
|
||||
"modeTab": {
|
||||
"link": "Odkaz",
|
||||
"user": "Uživatel"
|
||||
},
|
||||
"linkModeDescription": "Připojit se může kdokoli s tímto odkazem, účet není potřeba.",
|
||||
"userModeDescription": "Sdílejte s konkrétním uživatelem, který již má k tomuto hostiteli přístup. Pokud ještě nemá přístup, nejprve s ním hostitele sdílejte nebo použijte odkaz. Po sdílení se relace zobrazí na jeho kartě Připojení.",
|
||||
"permissionLevel": {
|
||||
"label": "Úroveň oprávnění",
|
||||
"readOnly": "Pouze pro čtení",
|
||||
"readOnlyDescription": "Může sledovat relaci živě, ale nemůže psát ani interagovat.",
|
||||
"readWrite": "Čtení a zápis",
|
||||
"readWriteDescription": "Může psát a interagovat s relací stejně jako její vlastník."
|
||||
},
|
||||
"expiryLabel": "Platnost vypršení platnosti odkazu",
|
||||
"createLinkButton": "Vytvořit odkaz",
|
||||
"createShareButton": "Sdílet s uživatelem",
|
||||
"searchUsersPlaceholder": "Hledat uživatele...",
|
||||
"noUsersFound": "Nenaleženi žádní uživatelé",
|
||||
"linkCreated": "Odkaz pro sdílení vytvořen",
|
||||
"linkNeedsServer": "Sdílení odkazů funguje pouze pro relace na serveru Termix. Pro sdílení tohoto hostitele pomocí odkazu jej připojte přes propojený server.",
|
||||
"linkCopied": "Odkaz zkopírován do schránky",
|
||||
"copyLink": "Kopírovat odkaz",
|
||||
"shareCreated": "Relace sdílena. Zobrazí se na jejich kartě Připojení.",
|
||||
"shareFailed": "Vytvoření sdílené položky se nezdařilo",
|
||||
"userLacksHostAccess": "Tento uživatel zatím nemá přístup k tomuto hostiteli. Nejprve s ním hostitele sdílejte nebo použijte odkaz.",
|
||||
"activeShares": "Aktivní akcie",
|
||||
"noActiveShares": "Pro tuto relaci nejsou žádné aktivní sdílení",
|
||||
"revoke": "Zrušit",
|
||||
"revokeConfirmTitle": "Zrušit toto sdílení?",
|
||||
"revokeConfirmDescription": "Každý, kdo tuto sdílenou složku používá, okamžitě ztratí přístup.",
|
||||
"revoked": "Podíl zrušen",
|
||||
"revokeFailed": "Zrušení sdílení se nezdařilo",
|
||||
"joinCount": "{{count}} připojit se",
|
||||
"joinCount_other": "{{count}} se připojí",
|
||||
"expiresAt": "Platnost vyprší {{date}}",
|
||||
"linkShareBadge": "Odkaz",
|
||||
"userShareBadge": "Uživatel: {{username}}",
|
||||
"loadSharesFailed": "Nepodařilo se načíst aktivní sdílení",
|
||||
"remoteDisplayUnavailable": "Vzdálená plocha momentálně není k dispozici.",
|
||||
"sharedWithMe": "Sezení sdílená se mnou",
|
||||
"customHoursPlaceholder": "Hodiny do vypršení přístupu"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nový pokoj",
|
||||
"roomName": "Název místnosti",
|
||||
"persistentRoom": "Trvalá místnost",
|
||||
"persistentRoomHint": "Trvalé místnosti zůstanou uvedeny i po skončení schůzky a lze je znovu použít.",
|
||||
"noRooms": "Zatím žádné místnosti. Vytvořte si novou a začněte schůzku.",
|
||||
"openRoom": "OTEVŘENO",
|
||||
"endRoom": "Ukončit schůzku",
|
||||
"leaveRoom": "Dovolená",
|
||||
"deleteMember": "Odstranit",
|
||||
"removeMember": "Odebrat {{name}}",
|
||||
"removeMemberTitle": "Odebrat tohoto člena?",
|
||||
"removeMemberDescription": "{{name}} okamžitě ztratí přístup k této místnosti. Jakýkoli aktivní SSH prohlížeč bude odpojen.",
|
||||
"invite": "Pozvat",
|
||||
"inviteTitle": "Pozvat členy",
|
||||
"members": "Členové",
|
||||
"membersPanel": "Členové místnosti a požadavky na ovládání",
|
||||
"membersWithCount": "Členové ({{count}})",
|
||||
"guestsWithCount": "Hosté ({{count}})",
|
||||
"removeGuests": "Odebrat hosty",
|
||||
"anonymousGuest": "Host",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(Vy)",
|
||||
"hostBadge": "Hostitel",
|
||||
"presenterBadge": "Prezentace",
|
||||
"present": "Současnost",
|
||||
"presentTitle": "Vyjdi na pódium",
|
||||
"presentHost": "Hostitel",
|
||||
"searchHosts": "Vyhledávání hostitelů",
|
||||
"noHostsFound": "Nebyli nalezeni žádní dostupní hostitelé.",
|
||||
"presentProtocol": "Protokol",
|
||||
"stopPresenting": "Zastavit prezentaci",
|
||||
"takeOver": "Převzít",
|
||||
"takeOverConfirmTitle": "Nahradit aktuální prezentaci?",
|
||||
"takeOverDescription": "{{name}} právě vystupuje. Převzetí vystoupení odpojí všechny od dané scény.",
|
||||
"emptyStage": "Nikdo neprezentuje. Vydejte se na pódium a sdílejte prezentaci.",
|
||||
"stageLoading": "Připojení k pódiu...",
|
||||
"stageEnded": "Prezentace skončila",
|
||||
"roomEnded": "Tato schůzka skončila",
|
||||
"presenterLabel": "{{name}} prezentuje",
|
||||
"guestLink": "Odkaz pro hosty",
|
||||
"guestLinkOn": "Odkaz pro hosty je zapnutý. Kdokoli s odkazem může sledovat pódium.",
|
||||
"guestLinkOff": "Hostovský odkaz je vypnutý.",
|
||||
"copyLink": "Kopírovat odkaz",
|
||||
"rotateLink": "Otočit odkaz",
|
||||
"disableLink": "Zakázat odkaz",
|
||||
"rotateLinkConfirmTitle": "Otočit odkaz pro hosty?",
|
||||
"rotateLinkDescription": "Aktuální odkaz okamžitě přestane fungovat. Připojení hosté přes SSH budou odpojeni.",
|
||||
"disableLinkConfirmTitle": "Zakázat odkaz pro hosty?",
|
||||
"disableLinkDescription": "Anonymní uživatelé SSH budou okamžitě odpojeni a aktuální odkaz přestane fungovat.",
|
||||
"linkCopied": "Odkaz zkopírován",
|
||||
"roles": "Role",
|
||||
"users": "Uživatelé",
|
||||
"searchUsers": "Vyhledat uživatele",
|
||||
"endConfirmTitle": "Ukončit tuto schůzku?",
|
||||
"endDescription": "{{name}} bude pro všechny ukončeno a nebude možné jej znovu otevřít.",
|
||||
"endPersistentDescription": "Aktivní prezentace v {{name}} se zastaví. Místnost zůstane k dispozici pro opětovné použití.",
|
||||
"deleteRoom": "Smazat místnost",
|
||||
"deleteConfirmTitle": "Smazat tuto místnost?",
|
||||
"deleteDescription": "{{name}} bude trvale smazáno pro všechny a nelze to vrátit zpět.",
|
||||
"invitedTo": "Byli jste pozváni na „{{name}}“",
|
||||
"guest": {
|
||||
"title": "Zasedání",
|
||||
"waiting": "Čekání na moderátora...",
|
||||
"linkInvalid": "Tento odkaz pro hosty je neplatný nebo schůzka skončila."
|
||||
},
|
||||
"reopenFromPanel": "Platnost této karty schůzky vypršela. Znovu otevřete místnost z panelu Schůzky.",
|
||||
"retry": "Zkusit znovu",
|
||||
"requestControl": "Požádat o kontrolu",
|
||||
"cancelControlRequest": "Zrušit požadavek",
|
||||
"controlQueue": "Žádosti o kontrolu",
|
||||
"noControlRequests": "Žádné čekající žádosti.",
|
||||
"grantControlTo": "Předejte kontrolu uživateli {{name}}",
|
||||
"dismissControlRequest": "Zamítnout požadavek uživatele {{name}}na kontrolu",
|
||||
"releaseControl": "Ovládání uvolnění",
|
||||
"grantControl": "Předejte kontrolu",
|
||||
"revokeControl": "Převezměte zpět kontrolu",
|
||||
"controlRequestedBy": "{{name}} požádal o kontrolu",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "Pod kontrolou",
|
||||
"youArePresenting": "Prezentujete na jiné kartě. Ukončete prezentaci, abyste mohli předat scénu.",
|
||||
"created": "Místnost vytvořena",
|
||||
"invited": "Pozvaní členové",
|
||||
"roomTab": "Zasedání",
|
||||
"serverChanged": "Server schůzky se změnil. Znovu otevřete tuto schůzku z panelu Schůzky.",
|
||||
"hostNotSynced": "Před prezentací synchronizujte tohoto hostitele se serverem schůzek.",
|
||||
"linkRequiresServer": "Propojte tuto plochu se serverem Termix a vytvořte tam schůzku pro sdílení webového odkazu."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Schůzky"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Povolit sdílení relace",
|
||||
"description": "Povolit sdílení relací živého terminálu, RDP, VNC a Telnet v rámci celé instance. Pokud je tato možnost zakázána, přepíše všechny přepínače sdílení pro jednotlivé hostitele."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Povolit sdílení relace",
|
||||
"description": "Umožnit sdílení živých relací na tomto hostiteli prostřednictvím odkazu nebo s ostatními uživateli"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Sdílení relací a připojení k místnostem",
|
||||
"description": "Sdílejte živé relace prostřednictvím odkazu nebo s ostatními uživateli a vytvářejte a připojujte se k místnostem pro spolupráci."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nikdy",
|
||||
"oneHour": "1 hodina",
|
||||
"oneDay": "24 hodin",
|
||||
"sevenDays": "7 dní",
|
||||
"thirtyDays": "30 dní",
|
||||
"custom": "Zvyk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Sdíleno se mnou",
|
||||
"sharedBy": "Sdíleno uživatelem {{username}}",
|
||||
"join": "Připojit se",
|
||||
"sharedSessionLabel": "{{hostName}} (sdílené)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Forbinder til delt session...",
|
||||
"linkInvalid": "Dette delingslink er ugyldigt, udløbet eller er blevet tilbagekaldt",
|
||||
"rateLimited": "For mange forsøg, prøv igen om lidt",
|
||||
"sessionEnded": "Denne session er afsluttet",
|
||||
"readOnlyBadge": "Kun visning",
|
||||
"ownerLabel": "Værtsejer"
|
||||
},
|
||||
"modalTitle": "Del session",
|
||||
"shareButton": "Dele",
|
||||
"notReadyToShare": "Sessionen er ikke klar til deling endnu",
|
||||
"modeTab": {
|
||||
"link": "Forbindelse",
|
||||
"user": "Bruger"
|
||||
},
|
||||
"linkModeDescription": "Alle med dette link kan tilmelde sig, ingen konto kræves.",
|
||||
"userModeDescription": "Del med en bestemt bruger, der allerede har adgang til denne vært. Hvis de ikke har adgang endnu, skal du først dele værten med dem eller bruge et link i stedet. Når sessionen er delt, vises den under deres fane Forbindelser.",
|
||||
"permissionLevel": {
|
||||
"label": "Tilladelsesniveau",
|
||||
"readOnly": "Skrivebeskyttet",
|
||||
"readOnlyDescription": "Kan se sessionen live, men kan ikke skrive eller interagere.",
|
||||
"readWrite": "Læs-skriv",
|
||||
"readWriteDescription": "Kan skrive og interagere med sessionen ligesom ejeren."
|
||||
},
|
||||
"expiryLabel": "Linkudløb",
|
||||
"createLinkButton": "Opret link",
|
||||
"createShareButton": "Del med brugeren",
|
||||
"searchUsersPlaceholder": "Søg efter brugere...",
|
||||
"noUsersFound": "Ingen brugere fundet",
|
||||
"linkCreated": "Delingslink oprettet",
|
||||
"linkNeedsServer": "Delingslinks fungerer kun for sessioner på en Termix-server. Forbind denne vært via din linkede server for at dele den med et link.",
|
||||
"linkCopied": "Link kopieret til udklipsholder",
|
||||
"copyLink": "Kopiér link",
|
||||
"shareCreated": "Session delt. Den vises i fanen Forbindelser.",
|
||||
"shareFailed": "Kunne ikke oprette deling",
|
||||
"userLacksHostAccess": "Den bruger har endnu ikke adgang til denne vært. Del værten med dem først, eller brug et link i stedet.",
|
||||
"activeShares": "Aktive aktier",
|
||||
"noActiveShares": "Ingen aktive delinger for denne session",
|
||||
"revoke": "Tilbagekald",
|
||||
"revokeConfirmTitle": "Tilbagekald denne deling?",
|
||||
"revokeConfirmDescription": "Enhver, der bruger denne deling, mister adgangen med det samme.",
|
||||
"revoked": "Deling tilbagekaldt",
|
||||
"revokeFailed": "Delingen kunne ikke tilbagekaldes",
|
||||
"joinCount": "{{count}} tilmeld dig",
|
||||
"joinCount_other": "{{count}} deltager",
|
||||
"expiresAt": "Udløber {{date}}",
|
||||
"linkShareBadge": "Forbindelse",
|
||||
"userShareBadge": "Bruger: {{username}}",
|
||||
"loadSharesFailed": "Kunne ikke indlæse aktive delinger",
|
||||
"remoteDisplayUnavailable": "Fjernskrivebord er ikke tilgængeligt lige nu.",
|
||||
"sharedWithMe": "Sessioner delt med mig",
|
||||
"customHoursPlaceholder": "Timer indtil adgangen udløber"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nyt værelse",
|
||||
"roomName": "Værelsesnavn",
|
||||
"persistentRoom": "Permanent rum",
|
||||
"persistentRoomHint": "Permanente rum forbliver på listen efter mødets afslutning og kan genbruges.",
|
||||
"noRooms": "Ingen rum endnu. Opret et for at starte et møde.",
|
||||
"openRoom": "Åben",
|
||||
"endRoom": "Afslut mødet",
|
||||
"leaveRoom": "Forlade",
|
||||
"deleteMember": "Fjerne",
|
||||
"removeMember": "Fjern {{name}}",
|
||||
"removeMemberTitle": "Fjern dette medlem?",
|
||||
"removeMemberDescription": "{{name}} mister adgangen til dette rum med det samme. Enhver aktiv SSH-seer vil blive afbrudt.",
|
||||
"invite": "Invitere",
|
||||
"inviteTitle": "Inviter medlemmer",
|
||||
"members": "Medlemmer",
|
||||
"membersPanel": "Rummedlemmer og kontrolanmodninger",
|
||||
"membersWithCount": "Medlemmer ({{count}})",
|
||||
"guestsWithCount": "Gæster ({{count}})",
|
||||
"removeGuests": "Fjern gæster",
|
||||
"anonymousGuest": "Gæst",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(du)",
|
||||
"hostBadge": "Vært",
|
||||
"presenterBadge": "Præsentation",
|
||||
"present": "Nuværende",
|
||||
"presentTitle": "Indtag scenen",
|
||||
"presentHost": "Vært",
|
||||
"searchHosts": "Søg efter værter",
|
||||
"noHostsFound": "Ingen tilgængelige værter fundet.",
|
||||
"presentProtocol": "Protokol",
|
||||
"stopPresenting": "Stop med at præsentere",
|
||||
"takeOver": "Overtag",
|
||||
"takeOverConfirmTitle": "Erstat den nuværende præsentation?",
|
||||
"takeOverDescription": "{{name}} præsenterer nu. Overtagelse vil afbryde alles forbindelse til den scene.",
|
||||
"emptyStage": "Ingen præsenterer. Gå på scenen for at dele en session.",
|
||||
"stageLoading": "Forbinder sig til scenen...",
|
||||
"stageEnded": "Præsentationen sluttede",
|
||||
"roomEnded": "Dette møde er afsluttet",
|
||||
"presenterLabel": "{{name}} præsenterer",
|
||||
"guestLink": "Gæstelink",
|
||||
"guestLinkOn": "Gæstelinket er aktiveret. Alle med linket kan se scenen.",
|
||||
"guestLinkOff": "Gæstelinket er slået fra.",
|
||||
"copyLink": "Kopiér link",
|
||||
"rotateLink": "Roter link",
|
||||
"disableLink": "Deaktiver link",
|
||||
"rotateLinkConfirmTitle": "Roter gæstelinket?",
|
||||
"rotateLinkDescription": "Det nuværende link vil stoppe med at virke med det samme. Forbundne SSH-gæster vil blive afbrudt.",
|
||||
"disableLinkConfirmTitle": "Deaktiver gæstelinket?",
|
||||
"disableLinkDescription": "Anonyme SSH-seere vil blive afbrudt med det samme, og det nuværende link vil stoppe med at virke.",
|
||||
"linkCopied": "Linket er kopieret",
|
||||
"roles": "Roller",
|
||||
"users": "Brugere",
|
||||
"searchUsers": "Søg brugere",
|
||||
"endConfirmTitle": "Afslutte dette møde?",
|
||||
"endDescription": "{{name}} slutter for alle og kan ikke genåbnes.",
|
||||
"endPersistentDescription": "Den aktive præsentation i {{name}} stopper. Rummet forbliver tilgængeligt til genbrug.",
|
||||
"deleteRoom": "Slet rum",
|
||||
"deleteConfirmTitle": "Slet dette rum?",
|
||||
"deleteDescription": "{{name}} vil blive slettet permanent for alle og kan ikke fortrydes.",
|
||||
"invitedTo": "Du blev inviteret til \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Møde",
|
||||
"waiting": "Venter på præsentatoren...",
|
||||
"linkInvalid": "Dette gæstelink er ugyldigt, eller mødet er afsluttet"
|
||||
},
|
||||
"reopenFromPanel": "Denne mødefane er udløbet. Genåbn rummet fra panelet Møder.",
|
||||
"retry": "Prøv igen",
|
||||
"requestControl": "Anmod om kontrol",
|
||||
"cancelControlRequest": "Annuller anmodning",
|
||||
"controlQueue": "Kontrolanmodninger",
|
||||
"noControlRequests": "Ingen ventende anmodninger.",
|
||||
"grantControlTo": "Giv kontrol til {{name}}",
|
||||
"dismissControlRequest": "Afvis kontrolanmodningen fra {{name}}",
|
||||
"releaseControl": "Slipkontrol",
|
||||
"grantControl": "Giv kontrol",
|
||||
"revokeControl": "Tag kontrollen tilbage",
|
||||
"controlRequestedBy": "{{name}} bad om kontrol",
|
||||
"grant": "Give",
|
||||
"controlBadge": "I kontrol",
|
||||
"youArePresenting": "Du præsenterer i en anden fane. Stop præsentationen for at overdrage scenen.",
|
||||
"created": "Rum oprettet",
|
||||
"invited": "Medlemmer inviteret",
|
||||
"roomTab": "Møde",
|
||||
"serverChanged": "Mødeserveren er ændret. Genåbn dette møde fra panelet Møder.",
|
||||
"hostNotSynced": "Synkroniser denne vært med mødeserveren før præsentation.",
|
||||
"linkRequiresServer": "Forbind dette skrivebord til en Termix-server, og opret mødet der for at dele et weblink."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Møder"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Tillad deling af sessioner",
|
||||
"description": "Tillad deling af live terminal-, RDP-, VNC- og Telnet-sessioner på tværs af instanser. Tilsidesætter alle delingsindstillinger pr. vært, når de er deaktiveret."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Tillad deling af sessioner",
|
||||
"description": "Tillad live-sessioner på denne vært at blive delt via link eller med andre brugere"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Del sessioner og deltag i rum",
|
||||
"description": "Del livesessioner via link eller med andre brugere, og opret og deltag i samarbejdsrum."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Aldrig",
|
||||
"oneHour": "1 time",
|
||||
"oneDay": "24 timer",
|
||||
"sevenDays": "7 dage",
|
||||
"thirtyDays": "30 dage",
|
||||
"custom": "Skik"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Delt med mig",
|
||||
"sharedBy": "Delt af {{username}}",
|
||||
"join": "Deltag",
|
||||
"sharedSessionLabel": "{{hostName}} (delt)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Verbindung zur gemeinsamen Sitzung wird hergestellt...",
|
||||
"linkInvalid": "Dieser Freigabelink ist ungültig, abgelaufen oder wurde widerrufen.",
|
||||
"rateLimited": "Zu viele Versuche, bitte versuchen Sie es in Kürze erneut.",
|
||||
"sessionEnded": "Diese Sitzung ist beendet.",
|
||||
"readOnlyBadge": "Nur Ansicht",
|
||||
"ownerLabel": "Gastgeber"
|
||||
},
|
||||
"modalTitle": "Sitzung teilen",
|
||||
"shareButton": "Teilen",
|
||||
"notReadyToShare": "Die Sitzung ist noch nicht zur Freigabe bereit.",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Benutzer"
|
||||
},
|
||||
"linkModeDescription": "Jeder mit diesem Link kann beitreten, ein Konto ist nicht erforderlich.",
|
||||
"userModeDescription": "Teilen Sie die Sitzung mit einem bestimmten Benutzer, der bereits Zugriff auf diesen Host hat. Falls er noch keinen Zugriff hat, teilen Sie ihm den Host zuerst mit oder verwenden Sie stattdessen einen Link. Nach der Freigabe erscheint die Sitzung im Tab „Verbindungen“ des Benutzers.",
|
||||
"permissionLevel": {
|
||||
"label": "Berechtigungsstufe",
|
||||
"readOnly": "Schreibgeschützt",
|
||||
"readOnlyDescription": "Ich kann die Sitzung live verfolgen, aber nicht tippen oder interagieren.",
|
||||
"readWrite": "Lesen und Schreiben",
|
||||
"readWriteDescription": "Kann in der Sitzung genauso tippen und mit ihr interagieren wie der Besitzer."
|
||||
},
|
||||
"expiryLabel": "Linkablauf",
|
||||
"createLinkButton": "Verknüpfung erstellen",
|
||||
"createShareButton": "Mit dem Nutzer teilen",
|
||||
"searchUsersPlaceholder": "Benutzer suchen...",
|
||||
"noUsersFound": "Keine Benutzer gefunden",
|
||||
"linkCreated": "Link zum Teilen erstellt",
|
||||
"linkNeedsServer": "Freigabelinks funktionieren nur für Sitzungen auf einem Termix-Server. Verbinden Sie diesen Host über Ihren verknüpften Server, um ihn per Link freizugeben.",
|
||||
"linkCopied": "Link in die Zwischenablage kopiert",
|
||||
"copyLink": "Link kopieren",
|
||||
"shareCreated": "Die Sitzung wurde freigegeben. Sie wird im Tab „Verbindungen“ des Benutzers angezeigt.",
|
||||
"shareFailed": "Fehler beim Erstellen der Freigabe",
|
||||
"userLacksHostAccess": "Dieser Benutzer hat noch keinen Zugriff auf diesen Host. Teilen Sie ihm den Host zuerst mit oder verwenden Sie stattdessen einen Link.",
|
||||
"activeShares": "Aktive Anteile",
|
||||
"noActiveShares": "Keine aktiven Anteile für diese Sitzung",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeConfirmTitle": "Diese Aktie widerrufen?",
|
||||
"revokeConfirmDescription": "Wer diese Freigabe nutzt, verliert sofort den Zugriff.",
|
||||
"revoked": "Anteil widerrufen",
|
||||
"revokeFailed": "Aktienwiderruf fehlgeschlagen",
|
||||
"joinCount": "{{count}} beitreten",
|
||||
"joinCount_other": "{{count}} verbindet sich",
|
||||
"expiresAt": "Läuft ab {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "Benutzer: {{username}}",
|
||||
"loadSharesFailed": "Fehler beim Laden der aktiven Anteile",
|
||||
"remoteDisplayUnavailable": "Remote-Desktop ist momentan nicht verfügbar.",
|
||||
"sharedWithMe": "Sitzungen, die mit mir geteilt wurden",
|
||||
"customHoursPlaceholder": "Stunden bis zum Zugriffsablauf"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Neues Zimmer",
|
||||
"roomName": "Zimmername",
|
||||
"persistentRoom": "Persistenter Raum",
|
||||
"persistentRoomHint": "Die gespeicherten Räume bleiben auch nach Ende des Meetings gelistet und können wiederverwendet werden.",
|
||||
"noRooms": "Noch keine Räume vorhanden. Erstellen Sie einen, um ein Meeting zu starten.",
|
||||
"openRoom": "Offen",
|
||||
"endRoom": "Ende der Besprechung",
|
||||
"leaveRoom": "Verlassen",
|
||||
"deleteMember": "Entfernen",
|
||||
"removeMember": "{{name}} entfernen",
|
||||
"removeMemberTitle": "Dieses Mitglied entfernen?",
|
||||
"removeMemberDescription": "{{name}} verliert sofort den Zugriff auf diesen Raum. Alle aktiven SSH-Viewer werden getrennt.",
|
||||
"invite": "Einladen",
|
||||
"inviteTitle": "Mitglieder einladen",
|
||||
"members": "Mitglieder",
|
||||
"membersPanel": "Raummitglieder und Kontrollanfragen",
|
||||
"membersWithCount": "Mitglieder ({{count}})",
|
||||
"guestsWithCount": "Gäste ({{count}})",
|
||||
"removeGuests": "Gäste entfernen",
|
||||
"anonymousGuest": "Gast",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(Du)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Präsentation",
|
||||
"present": "Gegenwärtig",
|
||||
"presentTitle": "Betretet die Bühne",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Hosts suchen",
|
||||
"noHostsFound": "Es wurden keine verfügbaren Hosts gefunden.",
|
||||
"presentProtocol": "Protokoll",
|
||||
"stopPresenting": "Hör auf zu präsentieren",
|
||||
"takeOver": "Übernehmen",
|
||||
"takeOverConfirmTitle": "Die aktuelle Präsentation ersetzen?",
|
||||
"takeOverDescription": "{{name}} präsentiert sich jetzt. Die Übernahme trennt alle von dieser Bühne.",
|
||||
"emptyStage": "Niemand präsentiert. Ergreife die Bühne, um eine Session zu teilen.",
|
||||
"stageLoading": "Verbindung zur Bühne...",
|
||||
"stageEnded": "Die Präsentation endete",
|
||||
"roomEnded": "Diese Sitzung ist beendet.",
|
||||
"presenterLabel": "{{name}} präsentiert",
|
||||
"guestLink": "Gastlink",
|
||||
"guestLinkOn": "Der Gastzugang ist aktiviert. Jeder mit dem Link kann die Bühnenshow verfolgen.",
|
||||
"guestLinkOff": "Der Gastlink ist deaktiviert.",
|
||||
"copyLink": "Link kopieren",
|
||||
"rotateLink": "Drehverbindung",
|
||||
"disableLink": "Link deaktivieren",
|
||||
"rotateLinkConfirmTitle": "Den Gastlink drehen?",
|
||||
"rotateLinkDescription": "Die bestehende Verbindung wird sofort unterbrochen. Verbundene SSH-Gäste werden getrennt.",
|
||||
"disableLinkConfirmTitle": "Gastlink deaktivieren?",
|
||||
"disableLinkDescription": "Anonyme SSH-Zugriffe werden sofort getrennt und die aktuelle Verbindung funktioniert nicht mehr.",
|
||||
"linkCopied": "Link kopiert",
|
||||
"roles": "Rollen",
|
||||
"users": "Benutzer",
|
||||
"searchUsers": "Nutzer suchen",
|
||||
"endConfirmTitle": "Diese Sitzung beenden?",
|
||||
"endDescription": "{{name}} wird für alle beendet und kann nicht wieder geöffnet werden.",
|
||||
"endPersistentDescription": "Die aktive Präsentation in {{name}} wird beendet. Der Raum steht weiterhin zur erneuten Verwendung zur Verfügung.",
|
||||
"deleteRoom": "Raum löschen",
|
||||
"deleteConfirmTitle": "Diesen Raum löschen?",
|
||||
"deleteDescription": "{{name}} wird für alle dauerhaft gelöscht und kann nicht rückgängig gemacht werden.",
|
||||
"invitedTo": "Sie wurden eingeladen zu \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Treffen",
|
||||
"waiting": "Ich warte auf den Moderator...",
|
||||
"linkInvalid": "Dieser Gastlink ist ungültig oder das Meeting ist beendet."
|
||||
},
|
||||
"reopenFromPanel": "Dieser Besprechungsreiter ist abgelaufen. Öffnen Sie den Raum erneut über das Besprechungsmenü.",
|
||||
"retry": "Erneut versuchen",
|
||||
"requestControl": "Anforderungskontrolle",
|
||||
"cancelControlRequest": "Stornierungsanfrage",
|
||||
"controlQueue": "Kontrollanfragen",
|
||||
"noControlRequests": "Keine offenen Anfragen.",
|
||||
"grantControlTo": "Übergib die Kontrolle an {{name}}",
|
||||
"dismissControlRequest": "Kontrollanfrage von {{name}}ablehnen",
|
||||
"releaseControl": "Freigabekontrolle",
|
||||
"grantControl": "Kontrolle abgeben",
|
||||
"revokeControl": "Übernehmen Sie wieder die Kontrolle",
|
||||
"controlRequestedBy": "{{name}} hat um Kontrolle gebeten",
|
||||
"grant": "Gewähren",
|
||||
"controlBadge": "Unter Kontrolle",
|
||||
"youArePresenting": "Sie präsentieren in einem anderen Tab. Beenden Sie Ihre Präsentation, um die Bühne zu übergeben.",
|
||||
"created": "Raum geschaffen",
|
||||
"invited": "Mitglieder eingeladen",
|
||||
"roomTab": "Treffen",
|
||||
"serverChanged": "Der Meeting-Server wurde geändert. Öffnen Sie dieses Meeting erneut über das Meeting-Panel.",
|
||||
"hostNotSynced": "Synchronisieren Sie diesen Host vor der Präsentation mit dem Meeting-Server.",
|
||||
"linkRequiresServer": "Verbinden Sie diesen Desktop mit einem Termix-Server und erstellen Sie dort das Meeting, um einen Weblink freizugeben."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Treffen"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Sitzungsfreigabe zulassen",
|
||||
"description": "Ermöglicht die instanzweite Freigabe von Live-Terminal-, RDP-, VNC- und Telnet-Sitzungen. Überschreibt alle Freigabeeinstellungen pro Host, wenn diese deaktiviert sind."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Sitzungsfreigabe zulassen",
|
||||
"description": "Ermöglichen Sie es, Live-Sitzungen auf diesem Host per Link oder mit anderen Benutzern zu teilen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Sitzungen teilen und Räumen beitreten",
|
||||
"description": "Teilen Sie Live-Sitzungen per Link oder mit anderen Nutzern und erstellen und treten Sie Kollaborationsräumen bei."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nie",
|
||||
"oneHour": "1 Stunde",
|
||||
"oneDay": "24 Stunden",
|
||||
"sevenDays": "7 Tage",
|
||||
"thirtyDays": "30 Tage",
|
||||
"custom": "Benutzerdefiniert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Mit mir geteilt",
|
||||
"sharedBy": "Geteilt von {{username}}",
|
||||
"join": "Verbinden",
|
||||
"sharedSessionLabel": "{{hostName}} (gemeinsam genutzt)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Σύνδεση σε κοινόχρηστη συνεδρία...",
|
||||
"linkInvalid": "Αυτός ο σύνδεσμος κοινής χρήσης δεν είναι έγκυρος, έχει λήξει ή έχει ανακληθεί",
|
||||
"rateLimited": "Πάρα πολλές προσπάθειες, δοκιμάστε ξανά σύντομα.",
|
||||
"sessionEnded": "Αυτή η συνεδρία έληξε",
|
||||
"readOnlyBadge": "Μόνο προβολή",
|
||||
"ownerLabel": "Κάτοχος κεντρικού υπολογιστή"
|
||||
},
|
||||
"modalTitle": "Κοινή χρήση περιόδου σύνδεσης",
|
||||
"shareButton": "Μερίδιο",
|
||||
"notReadyToShare": "Η συνεδρία δεν είναι ακόμη έτοιμη για κοινή χρήση",
|
||||
"modeTab": {
|
||||
"link": "Σύνδεσμος",
|
||||
"user": "Μεταχειριζόμενος"
|
||||
},
|
||||
"linkModeDescription": "Οποιοσδήποτε έχει αυτόν τον σύνδεσμο μπορεί να συμμετάσχει, δεν απαιτείται λογαριασμός.",
|
||||
"userModeDescription": "Κοινοποίηση με έναν συγκεκριμένο χρήστη που έχει ήδη πρόσβαση σε αυτόν τον κεντρικό υπολογιστή. Εάν δεν έχει ακόμη πρόσβαση, μοιραστείτε πρώτα τον κεντρικό υπολογιστή μαζί του ή χρησιμοποιήστε έναν σύνδεσμο. Μόλις κοινοποιηθεί, η συνεδρία εμφανίζεται στην καρτέλα Συνδέσεις.",
|
||||
"permissionLevel": {
|
||||
"label": "Επίπεδο δικαιωμάτων",
|
||||
"readOnly": "Μόνο για ανάγνωση",
|
||||
"readOnlyDescription": "Μπορώ να παρακολουθήσω την συνεδρία ζωντανά, αλλά δεν μπορώ να πληκτρολογήσω ή να αλληλεπιδράσω.",
|
||||
"readWrite": "Ανάγνωση-εγγραφή",
|
||||
"readWriteDescription": "Μπορεί να πληκτρολογήσει και να αλληλεπιδράσει με την περίοδο λειτουργίας όπως ο κάτοχος."
|
||||
},
|
||||
"expiryLabel": "Λήξη σύνδεσης",
|
||||
"createLinkButton": "Δημιουργία συνδέσμου",
|
||||
"createShareButton": "Κοινοποίηση με χρήστη",
|
||||
"searchUsersPlaceholder": "Αναζήτηση χρηστών...",
|
||||
"noUsersFound": "Δεν βρέθηκαν χρήστες",
|
||||
"linkCreated": "Ο σύνδεσμος κοινής χρήσης δημιουργήθηκε",
|
||||
"linkNeedsServer": "Οι σύνδεσμοι κοινής χρήσης λειτουργούν μόνο για συνεδρίες σε διακομιστή Termix. Συνδέστε αυτόν τον κεντρικό υπολογιστή μέσω του συνδεδεμένου διακομιστή σας για να τον κοινοποιήσετε με έναν σύνδεσμο.",
|
||||
"linkCopied": "Ο σύνδεσμος αντιγράφηκε στο πρόχειρο",
|
||||
"copyLink": "Αντιγραφή συνδέσμου",
|
||||
"shareCreated": "Η συνεδρία κοινοποιήθηκε. Θα εμφανιστεί στην καρτέλα \"Συνδέσεις\".",
|
||||
"shareFailed": "Η δημιουργία κοινής χρήσης απέτυχε",
|
||||
"userLacksHostAccess": "Αυτός ο χρήστης δεν έχει ακόμη πρόσβαση σε αυτόν τον κεντρικό υπολογιστή. Κοινοποιήστε πρώτα τον κεντρικό υπολογιστή μαζί του ή χρησιμοποιήστε έναν σύνδεσμο.",
|
||||
"activeShares": "Ενεργές κοινοποιήσεις",
|
||||
"noActiveShares": "Δεν υπάρχουν ενεργές κοινοποιήσεις για αυτήν την περίοδο σύνδεσης",
|
||||
"revoke": "Ανακαλώ",
|
||||
"revokeConfirmTitle": "Ανάκληση αυτής της κοινοποίησης;",
|
||||
"revokeConfirmDescription": "Όποιος χρησιμοποιεί αυτήν την κοινοποίηση θα χάσει αμέσως την πρόσβαση.",
|
||||
"revoked": "Η κοινοποίηση ανακλήθηκε",
|
||||
"revokeFailed": "Η ανάκληση της κοινής χρήσης απέτυχε",
|
||||
"joinCount": "{{count}} συμμετοχή",
|
||||
"joinCount_other": "{{count}} ενώσεις",
|
||||
"expiresAt": "Λήγει {{date}}",
|
||||
"linkShareBadge": "Σύνδεσμος",
|
||||
"userShareBadge": "Χρήστης: {{username}}",
|
||||
"loadSharesFailed": "Αποτυχία φόρτωσης ενεργών κοινόχρηστων στοιχείων",
|
||||
"remoteDisplayUnavailable": "Η απομακρυσμένη επιφάνεια εργασίας δεν είναι διαθέσιμη αυτήν τη στιγμή.",
|
||||
"sharedWithMe": "Συνεδρίες που κοινοποιήθηκαν σε εμένα",
|
||||
"customHoursPlaceholder": "Ώρες μέχρι τη λήξη της πρόσβασης"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Νέο δωμάτιο",
|
||||
"roomName": "Όνομα δωματίου",
|
||||
"persistentRoom": "Μόνιμο δωμάτιο",
|
||||
"persistentRoomHint": "Οι μόνιμες αίθουσες παραμένουν καταχωρημένες μετά το τέλος της σύσκεψης και μπορούν να επαναχρησιμοποιηθούν.",
|
||||
"noRooms": "Δεν υπάρχουν ακόμη αίθουσες. Δημιουργήστε μία για να ξεκινήσετε μια σύσκεψη.",
|
||||
"openRoom": "Ανοιχτό",
|
||||
"endRoom": "Λήξη συνάντησης",
|
||||
"leaveRoom": "Αδεια",
|
||||
"deleteMember": "Αφαιρώ",
|
||||
"removeMember": "Αφαίρεση {{name}}",
|
||||
"removeMemberTitle": "Κατάργηση αυτού του μέλους;",
|
||||
"removeMemberDescription": "Ο χρήστης {{name}} θα χάσει αμέσως την πρόσβαση σε αυτό το δωμάτιο. Οποιοσδήποτε ενεργός χρήστης προβολής SSH θα αποσυνδεθεί.",
|
||||
"invite": "Καλώ",
|
||||
"inviteTitle": "Πρόσκληση μελών",
|
||||
"members": "Μέλη",
|
||||
"membersPanel": "Μέλη δωματίου και αιτήματα ελέγχου",
|
||||
"membersWithCount": "Μέλη ({{count}})",
|
||||
"guestsWithCount": "Επισκέπτες ({{count}})",
|
||||
"removeGuests": "Αφαίρεση επισκεπτών",
|
||||
"anonymousGuest": "Επισκέπτης",
|
||||
"online": "διαδικτυακά",
|
||||
"offline": "εκτός σύνδεσης",
|
||||
"you": "(εσείς)",
|
||||
"hostBadge": "Πλήθος",
|
||||
"presenterBadge": "Παρουσίαση",
|
||||
"present": "Παρόν",
|
||||
"presentTitle": "Ανέβα στη σκηνή",
|
||||
"presentHost": "Πλήθος",
|
||||
"searchHosts": "Αναζήτηση κεντρικών υπολογιστών",
|
||||
"noHostsFound": "Δεν βρέθηκαν διαθέσιμοι κεντρικοί υπολογιστές.",
|
||||
"presentProtocol": "Πρωτόκολλο",
|
||||
"stopPresenting": "Διακοπή παρουσίασης",
|
||||
"takeOver": "Αναλαμβάνω",
|
||||
"takeOverConfirmTitle": "Αντικατάσταση της τρέχουσας παρουσίασης;",
|
||||
"takeOverDescription": "Το {{name}} παρουσιάζεται τώρα. Η ανάληψη του ελέγχου θα αποσυνδέσει όλους από αυτό το στάδιο.",
|
||||
"emptyStage": "Κανείς δεν κάνει παρουσίαση. Ανεβείτε στη σκηνή για να μοιραστείτε μια συνεδρία.",
|
||||
"stageLoading": "Σύνδεση με τη σκηνή...",
|
||||
"stageEnded": "Η παρουσίαση έληξε",
|
||||
"roomEnded": "Αυτή η συνάντηση έληξε",
|
||||
"presenterLabel": "{{name}} παρουσιάζει",
|
||||
"guestLink": "Σύνδεσμος επισκέπτη",
|
||||
"guestLinkOn": "Ο σύνδεσμος επισκεπτών είναι ενεργοποιημένος. Όποιος έχει τον σύνδεσμο μπορεί να παρακολουθήσει τη σκηνή.",
|
||||
"guestLinkOff": "Ο σύνδεσμος επισκέπτη είναι απενεργοποιημένος.",
|
||||
"copyLink": "Αντιγραφή συνδέσμου",
|
||||
"rotateLink": "Περιστροφή συνδέσμου",
|
||||
"disableLink": "Απενεργοποίηση συνδέσμου",
|
||||
"rotateLinkConfirmTitle": "Εναλλαγή του συνδέσμου επισκέπτη;",
|
||||
"rotateLinkDescription": "Ο τρέχων σύνδεσμος θα σταματήσει να λειτουργεί αμέσως. Οι συνδεδεμένοι επισκέπτες SSH θα αποσυνδεθούν.",
|
||||
"disableLinkConfirmTitle": "Απενεργοποίηση του συνδέσμου επισκέπτη;",
|
||||
"disableLinkDescription": "Οι ανώνυμοι θεατές SSH θα αποσυνδεθούν αμέσως και ο τρέχων σύνδεσμος θα σταματήσει να λειτουργεί.",
|
||||
"linkCopied": "Ο σύνδεσμος αντιγράφηκε",
|
||||
"roles": "Ρόλοι",
|
||||
"users": "Χρήστες",
|
||||
"searchUsers": "Αναζήτηση χρηστών",
|
||||
"endConfirmTitle": "Λήξη αυτής της συνάντησης;",
|
||||
"endDescription": "Το {{name}} θα λήξει για όλους και δεν μπορεί να ανοίξει ξανά.",
|
||||
"endPersistentDescription": "Η ενεργή παρουσίαση στο {{name}} θα σταματήσει. Η αίθουσα παραμένει διαθέσιμη για επαναχρησιμοποίηση.",
|
||||
"deleteRoom": "Διαγραφή δωματίου",
|
||||
"deleteConfirmTitle": "Διαγραφή αυτού του δωματίου;",
|
||||
"deleteDescription": "Η εντολή {{name}} θα διαγραφεί οριστικά για όλους και δεν θα είναι δυνατή η αναίρεση της.",
|
||||
"invitedTo": "Σας προσκάλεσαν στο \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Συνάντηση",
|
||||
"waiting": "Περιμένοντας τον παρουσιαστή...",
|
||||
"linkInvalid": "Αυτός ο σύνδεσμος επισκέπτη δεν είναι έγκυρος ή η σύσκεψη έχει λήξει"
|
||||
},
|
||||
"reopenFromPanel": "Αυτή η καρτέλα σύσκεψης έληξε. Ανοίξτε ξανά την αίθουσα από τον πίνακα \"Συνέδρια\".",
|
||||
"retry": "Δοκιμάζω πάλι",
|
||||
"requestControl": "Αίτημα ελέγχου",
|
||||
"cancelControlRequest": "Αίτημα ακύρωσης",
|
||||
"controlQueue": "Αιτήματα ελέγχου",
|
||||
"noControlRequests": "Δεν υπάρχουν εκκρεμή αιτήματα.",
|
||||
"grantControlTo": "Δώστε τον έλεγχο σε {{name}}",
|
||||
"dismissControlRequest": "Απόρριψη αιτήματος ελέγχου του {{name}}",
|
||||
"releaseControl": "Έλεγχος απελευθέρωσης",
|
||||
"grantControl": "Δώστε τον έλεγχο",
|
||||
"revokeControl": "Πάρε πίσω τον έλεγχο",
|
||||
"controlRequestedBy": "{{name}} ζήτησε έλεγχο",
|
||||
"grant": "Χορήγηση",
|
||||
"controlBadge": "Υπό έλεγχο",
|
||||
"youArePresenting": "Κάνετε παρουσίαση σε άλλη καρτέλα. Διακόψτε την παρουσίαση για να παραδώσετε τη σκηνή.",
|
||||
"created": "Δημιουργήθηκε δωμάτιο",
|
||||
"invited": "Μέλη που έχουν προσκληθεί",
|
||||
"roomTab": "Συνάντηση",
|
||||
"serverChanged": "Ο διακομιστής συσκέψεων άλλαξε. Ανοίξτε ξανά αυτήν τη σύσκεψη από τον πίνακα \"Συσκέψεις\".",
|
||||
"hostNotSynced": "Συγχρονίστε αυτόν τον κεντρικό υπολογιστή με τον διακομιστή συσκέψεων πριν από την παρουσίαση.",
|
||||
"linkRequiresServer": "Συνδέστε αυτήν την επιφάνεια εργασίας με έναν διακομιστή Termix και δημιουργήστε τη σύσκεψη εκεί για να μοιραστείτε έναν σύνδεσμο ιστού."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Συναντήσεις"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Επιτρέψτε την κοινή χρήση περιόδου σύνδεσης",
|
||||
"description": "Επιτρέπεται η κοινή χρήση των ζωντανών συνεδριών τερματικού, RDP, VNC και Telnet σε ολόκληρη την παρουσία. Παρακάμπτει κάθε εναλλαγή κοινής χρήσης ανά κεντρικό υπολογιστή όταν είναι απενεργοποιημένη."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Επιτρέψτε την κοινή χρήση περιόδου σύνδεσης",
|
||||
"description": "Να επιτρέπεται η κοινοποίηση ζωντανών συνεδριών σε αυτόν τον πάροχο μέσω συνδέσμου ή με άλλους χρήστες"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Κοινή χρήση συνεδριών και συμμετοχή σε αίθουσες",
|
||||
"description": "Μοιραστείτε ζωντανές συνεδρίες μέσω συνδέσμου ή με άλλους χρήστες και δημιουργήστε και συμμετάσχετε σε αίθουσες συνεργασίας."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Ποτέ",
|
||||
"oneHour": "1 ώρα",
|
||||
"oneDay": "24 ώρες",
|
||||
"sevenDays": "7 ημέρες",
|
||||
"thirtyDays": "30 ημέρες",
|
||||
"custom": "Εθιμο"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Κοινοποιήθηκε σε εμένα",
|
||||
"sharedBy": "Κοινοποίηση από {{username}}",
|
||||
"join": "Ενώνω",
|
||||
"sharedSessionLabel": "{{hostName}} (κοινόχρηστο)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Conectando con la sesión compartida...",
|
||||
"linkInvalid": "Este enlace para compartir no es válido, ha caducado o ha sido revocado.",
|
||||
"rateLimited": "Demasiados intentos, por favor inténtelo de nuevo en breve.",
|
||||
"sessionEnded": "Esta sesión ha finalizado.",
|
||||
"readOnlyBadge": "Solo lectura",
|
||||
"ownerLabel": "Propietario del anfitrión"
|
||||
},
|
||||
"modalTitle": "Compartir sesión",
|
||||
"shareButton": "Compartir",
|
||||
"notReadyToShare": "La sesión aún no está lista para compartirse.",
|
||||
"modeTab": {
|
||||
"link": "Enlace",
|
||||
"user": "Usuario"
|
||||
},
|
||||
"linkModeDescription": "Cualquier persona con este enlace puede unirse, no se requiere cuenta.",
|
||||
"userModeDescription": "Comparte la sesión con un usuario específico que ya tenga acceso a este host. Si aún no tiene acceso, comparte primero el host con él o utiliza un enlace. Una vez compartida, la sesión aparecerá en su pestaña Conexiones.",
|
||||
"permissionLevel": {
|
||||
"label": "Nivel de permiso",
|
||||
"readOnly": "Solo lectura",
|
||||
"readOnlyDescription": "Se puede ver la sesión en directo, pero no se puede escribir ni interactuar.",
|
||||
"readWrite": "Leer y escribir",
|
||||
"readWriteDescription": "Puede escribir e interactuar con la sesión igual que el propietario."
|
||||
},
|
||||
"expiryLabel": "Caducidad del enlace",
|
||||
"createLinkButton": "Crear enlace",
|
||||
"createShareButton": "Compartir con el usuario",
|
||||
"searchUsersPlaceholder": "Buscar usuarios...",
|
||||
"noUsersFound": "No se encontraron usuarios",
|
||||
"linkCreated": "Enlace para compartir creado",
|
||||
"linkNeedsServer": "Los enlaces para compartir solo funcionan para sesiones en un servidor Termix. Conecta este host a través de tu servidor vinculado para compartirlo mediante un enlace.",
|
||||
"linkCopied": "Enlace copiado al portapapeles",
|
||||
"copyLink": "Copiar enlace",
|
||||
"shareCreated": "Sesión compartida. Aparecerá en su pestaña Conexiones.",
|
||||
"shareFailed": "No se pudo crear el recurso compartido",
|
||||
"userLacksHostAccess": "Ese usuario aún no tiene acceso a este servidor. Primero, comparte el servidor con él o utiliza un enlace.",
|
||||
"activeShares": "Acciones activas",
|
||||
"noActiveShares": "No hay acciones activas para esta sesión.",
|
||||
"revoke": "Revocar",
|
||||
"revokeConfirmTitle": "¿Revocar esta acción?",
|
||||
"revokeConfirmDescription": "Cualquier persona que utilice este recurso compartido perderá el acceso inmediatamente.",
|
||||
"revoked": "Acción revocada",
|
||||
"revokeFailed": "No se pudo revocar la participación.",
|
||||
"joinCount": "{{count}} unirse",
|
||||
"joinCount_other": "{{count}} se une",
|
||||
"expiresAt": "Caduca {{date}}",
|
||||
"linkShareBadge": "Enlace",
|
||||
"userShareBadge": "Usuario: {{username}}",
|
||||
"loadSharesFailed": "No se pudieron cargar los recursos compartidos activos.",
|
||||
"remoteDisplayUnavailable": "El acceso remoto al escritorio no está disponible en este momento.",
|
||||
"sharedWithMe": "Sesiones compartidas conmigo",
|
||||
"customHoursPlaceholder": "Horas hasta que caduque el acceso"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Habitación nueva",
|
||||
"roomName": "Nombre de la habitación",
|
||||
"persistentRoom": "Habitación persistente",
|
||||
"persistentRoomHint": "Las salas reservadas permanecen en la lista una vez finalizada la reunión y pueden volver a utilizarse.",
|
||||
"noRooms": "Aún no hay salas disponibles. Crea una para iniciar una reunión.",
|
||||
"openRoom": "Abrir",
|
||||
"endRoom": "Fin de la reunión",
|
||||
"leaveRoom": "Dejar",
|
||||
"deleteMember": "Quitar",
|
||||
"removeMember": "Eliminar {{name}}",
|
||||
"removeMemberTitle": "¿Eliminar a este miembro?",
|
||||
"removeMemberDescription": "{{name}} perderá el acceso a esta sala inmediatamente. Cualquier visor SSH activo será desconectado.",
|
||||
"invite": "Invitar",
|
||||
"inviteTitle": "Invitar a los miembros",
|
||||
"members": "Miembros",
|
||||
"membersPanel": "Miembros de la sala y solicitudes de control",
|
||||
"membersWithCount": "Miembros ({{count}})",
|
||||
"guestsWithCount": "Invitados ({{count}})",
|
||||
"removeGuests": "Eliminar invitados",
|
||||
"anonymousGuest": "Invitado",
|
||||
"online": "en línea",
|
||||
"offline": "desconectado",
|
||||
"you": "(tú)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presentación",
|
||||
"present": "Presente",
|
||||
"presentTitle": "Sube al escenario",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Buscar hosts",
|
||||
"noHostsFound": "No se encontraron hosts disponibles.",
|
||||
"presentProtocol": "Protocolo",
|
||||
"stopPresenting": "Deja de presentar",
|
||||
"takeOver": "Tomar el control",
|
||||
"takeOverConfirmTitle": "¿Reemplazar la presentación actual?",
|
||||
"takeOverDescription": "{{name}} se está presentando ahora. Tomar el control desconectará a todos de ese escenario.",
|
||||
"emptyStage": "Nadie va a presentar. Sube al escenario para compartir una sesión.",
|
||||
"stageLoading": "Conectando con el escenario...",
|
||||
"stageEnded": "La presentación terminó",
|
||||
"roomEnded": "Esta reunión ha terminado.",
|
||||
"presenterLabel": "{{name}} está presentando",
|
||||
"guestLink": "Enlace de invitado",
|
||||
"guestLinkOn": "El enlace para invitados está activado. Cualquiera que tenga el enlace puede ver el espectáculo.",
|
||||
"guestLinkOff": "El enlace de invitado está desactivado.",
|
||||
"copyLink": "Copiar enlace",
|
||||
"rotateLink": "Enlace giratorio",
|
||||
"disableLink": "Deshabilitar enlace",
|
||||
"rotateLinkConfirmTitle": "¿Rotar el enlace de invitado?",
|
||||
"rotateLinkDescription": "El enlace actual dejará de funcionar inmediatamente. Los invitados SSH conectados serán desconectados.",
|
||||
"disableLinkConfirmTitle": "¿Deshabilitar el enlace de invitado?",
|
||||
"disableLinkDescription": "Los usuarios que intenten acceder de forma anónima mediante SSH serán desconectados inmediatamente y el enlace actual dejará de funcionar.",
|
||||
"linkCopied": "Enlace copiado",
|
||||
"roles": "Roles",
|
||||
"users": "Usuarios",
|
||||
"searchUsers": "Buscar usuarios",
|
||||
"endConfirmTitle": "¿Dar por terminada esta reunión?",
|
||||
"endDescription": "{{name}} finalizará para todos y no podrá reabrirse.",
|
||||
"endPersistentDescription": "La presentación activa en {{name}} se detendrá. La sala permanece disponible para su reutilización.",
|
||||
"deleteRoom": "Eliminar habitación",
|
||||
"deleteConfirmTitle": "¿Eliminar esta habitación?",
|
||||
"deleteDescription": "{{name}} se eliminará permanentemente para todos y no se podrá deshacer.",
|
||||
"invitedTo": "Te invitaron a \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Reunión",
|
||||
"waiting": "Esperando al presentador...",
|
||||
"linkInvalid": "Este enlace de invitado no es válido o la reunión ha finalizado."
|
||||
},
|
||||
"reopenFromPanel": "Esta pestaña de reunión ha caducado. Vuelva a abrir la sala desde el panel de Reuniones.",
|
||||
"retry": "Reintentar",
|
||||
"requestControl": "Control de solicitudes",
|
||||
"cancelControlRequest": "Cancelar solicitud",
|
||||
"controlQueue": "Solicitudes de control",
|
||||
"noControlRequests": "No hay solicitudes pendientes.",
|
||||
"grantControlTo": "Cede el control a {{name}}",
|
||||
"dismissControlRequest": "Rechazar la solicitud de control de {{name}}",
|
||||
"releaseControl": "Control de liberación",
|
||||
"grantControl": "Cede el control",
|
||||
"revokeControl": "Recupera el control",
|
||||
"controlRequestedBy": "{{name}} pidió control",
|
||||
"grant": "Conceder",
|
||||
"controlBadge": "En control",
|
||||
"youArePresenting": "Estás presentando en otra pestaña. Deja de presentar para ceder la palabra.",
|
||||
"created": "Habitación creada",
|
||||
"invited": "Miembros invitados",
|
||||
"roomTab": "Reunión",
|
||||
"serverChanged": "El servidor de la reunión ha cambiado. Vuelva a abrir esta reunión desde el panel de Reuniones.",
|
||||
"hostNotSynced": "Sincroniza este host con el servidor de la reunión antes de la presentación.",
|
||||
"linkRequiresServer": "Conecta este ordenador de escritorio a un servidor Termix y crea la reunión allí para compartir un enlace web."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Reuniones"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Permitir compartir la sesión",
|
||||
"description": "Permite compartir sesiones de terminal en vivo, RDP, VNC y Telnet en toda la instancia. Cuando está desactivado, anula cualquier configuración de uso compartido por host."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Permitir compartir la sesión",
|
||||
"description": "Permitir que las sesiones en vivo en este servidor se compartan mediante un enlace o con otros usuarios."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Comparte sesiones y únete a salas",
|
||||
"description": "Comparte sesiones en directo mediante un enlace o con otros usuarios, y crea y únete a salas de colaboración."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nunca",
|
||||
"oneHour": "1 hora",
|
||||
"oneDay": "24 horas",
|
||||
"sevenDays": "7 días",
|
||||
"thirtyDays": "30 días",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Compartido conmigo",
|
||||
"sharedBy": "Compartido por {{username}}",
|
||||
"join": "Unirse",
|
||||
"sharedSessionLabel": "{{hostName}} (compartido)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Yhdistetään jaettuun istuntoon...",
|
||||
"linkInvalid": "Tämä jakolinkki on virheellinen, vanhentunut tai se on peruutettu.",
|
||||
"rateLimited": "Liikaa yrityksiä, yritä uudelleen pian",
|
||||
"sessionEnded": "Tämä istunto on päättynyt",
|
||||
"readOnlyBadge": "Vain katselu",
|
||||
"ownerLabel": "Isännän omistaja"
|
||||
},
|
||||
"modalTitle": "Jaa istunto",
|
||||
"shareButton": "Jakaa",
|
||||
"notReadyToShare": "Istunto ei ole vielä valmis jaettavaksi",
|
||||
"modeTab": {
|
||||
"link": "Linkki",
|
||||
"user": "Käyttäjä"
|
||||
},
|
||||
"linkModeDescription": "Kuka tahansa, jolla on tämä linkki, voi liittyä, tiliä ei tarvita.",
|
||||
"userModeDescription": "Jaa tietyn käyttäjän kanssa, jolla on jo pääsy tähän isäntään. Jos heillä ei vielä ole pääsyä, jaa isäntä ensin heidän kanssaan tai käytä linkkiä. Jakamisen jälkeen istunto näkyy heidän Yhteydet-välilehdellään.",
|
||||
"permissionLevel": {
|
||||
"label": "Käyttöoikeustaso",
|
||||
"readOnly": "Vain lukuoikeus",
|
||||
"readOnlyDescription": "Voit katsoa istuntoa suorana, mutta et voi kirjoittaa tai olla vuorovaikutuksessa.",
|
||||
"readWrite": "Lue ja kirjoita",
|
||||
"readWriteDescription": "Voi kirjoittaa ja olla vuorovaikutuksessa istunnon kanssa aivan kuten omistaja."
|
||||
},
|
||||
"expiryLabel": "Linkin vanheneminen",
|
||||
"createLinkButton": "Luo linkki",
|
||||
"createShareButton": "Jaa käyttäjän kanssa",
|
||||
"searchUsersPlaceholder": "Hae käyttäjiä...",
|
||||
"noUsersFound": "Ei käyttäjiä löytynyt",
|
||||
"linkCreated": "Jaa linkki luotu",
|
||||
"linkNeedsServer": "Jakolinkit toimivat vain Termix-palvelimella olevissa istunnoissa. Yhdistä tämä isäntä linkitetyn palvelimesi kautta jakaaksesi sen linkillä.",
|
||||
"linkCopied": "Linkki kopioitu leikepöydälle",
|
||||
"copyLink": "Kopioi linkki",
|
||||
"shareCreated": "Istunto jaettu. Se näkyy heidän Yhteydet-välilehdellä.",
|
||||
"shareFailed": "Jaon luominen epäonnistui",
|
||||
"userLacksHostAccess": "Käyttäjällä ei ole vielä pääsyä tähän isäntään. Jaa isäntä ensin heidän kanssaan tai käytä linkkiä.",
|
||||
"activeShares": "Aktiiviset jaot",
|
||||
"noActiveShares": "Ei aktiivisia jakoja tällä istunnolla",
|
||||
"revoke": "Peruuttaa",
|
||||
"revokeConfirmTitle": "Perutaanko tämä jako?",
|
||||
"revokeConfirmDescription": "Kuka tahansa tätä jakoa käyttävä menettää käyttöoikeuden välittömästi.",
|
||||
"revoked": "Jako peruttu",
|
||||
"revokeFailed": "Jakamisen peruuttaminen epäonnistui",
|
||||
"joinCount": "{{count}} liity",
|
||||
"joinCount_other": "{{count}} liitokset",
|
||||
"expiresAt": "Vanhenee {{date}}",
|
||||
"linkShareBadge": "Linkki",
|
||||
"userShareBadge": "Käyttäjä: {{username}}",
|
||||
"loadSharesFailed": "Aktiivisten jakojen lataaminen epäonnistui",
|
||||
"remoteDisplayUnavailable": "Etätyöpöytä ei ole käytettävissä juuri nyt.",
|
||||
"sharedWithMe": "Kanssani jaetut sessiot",
|
||||
"customHoursPlaceholder": "Tuntia käyttöoikeuden vanhenemiseen"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Uusi huone",
|
||||
"roomName": "Huoneen nimi",
|
||||
"persistentRoom": "Pysyvä huone",
|
||||
"persistentRoomHint": "Pysyvät huoneet pysyvät luettelossa kokouksen päättymisen jälkeen ja niitä voidaan käyttää uudelleen.",
|
||||
"noRooms": "Ei vielä huoneita. Luo huone aloittaaksesi kokouksen.",
|
||||
"openRoom": "Avata",
|
||||
"endRoom": "Kokouksen loppu",
|
||||
"leaveRoom": "Lähtö",
|
||||
"deleteMember": "Poistaa",
|
||||
"removeMember": "Poista {{name}}",
|
||||
"removeMemberTitle": "Poistetaanko tämä jäsen?",
|
||||
"removeMemberDescription": "{{name}} menettää pääsyn tähän huoneeseen välittömästi. Kaikki aktiiviset SSH-katseluohjelmat katkaistaan.",
|
||||
"invite": "Kutsu",
|
||||
"inviteTitle": "Kutsu jäseniä",
|
||||
"members": "Jäsenet",
|
||||
"membersPanel": "Huoneen jäsenet ja hallintapyynnöt",
|
||||
"membersWithCount": "Jäsenet ({{count}})",
|
||||
"guestsWithCount": "Vieraat ({{count}})",
|
||||
"removeGuests": "Poista vieraat",
|
||||
"anonymousGuest": "Vieras",
|
||||
"online": "verkossa",
|
||||
"offline": "offline-tilassa",
|
||||
"you": "(sinä)",
|
||||
"hostBadge": "Isäntä",
|
||||
"presenterBadge": "Esittäminen",
|
||||
"present": "Esittää",
|
||||
"presentTitle": "Nouse lavalle",
|
||||
"presentHost": "Isäntä",
|
||||
"searchHosts": "Hae isäntiä",
|
||||
"noHostsFound": "Ei saatavilla olevia isäntiä.",
|
||||
"presentProtocol": "Protokolla",
|
||||
"stopPresenting": "Lopeta esittäminen",
|
||||
"takeOver": "Ota haltuunsa",
|
||||
"takeOverConfirmTitle": "Korvataanko nykyinen esitys?",
|
||||
"takeOverDescription": "{{name}} esittää nyt. Hallinnan ottaminen irrottaa kaikki yhteydet kyseiseen vaiheeseen.",
|
||||
"emptyStage": "Kukaan ei esitä. Nouse lavalle ja jaa sessio.",
|
||||
"stageLoading": "Yhdistetään lavalle...",
|
||||
"stageEnded": "Esitys päättyi",
|
||||
"roomEnded": "Tämä kokous on päättynyt",
|
||||
"presenterLabel": "{{name}} esittää",
|
||||
"guestLink": "Vieraslinkki",
|
||||
"guestLinkOn": "Vieraslinkki on käytössä. Kuka tahansa linkin omaava voi katsoa lavaa.",
|
||||
"guestLinkOff": "Vierailijalinkki on pois päältä.",
|
||||
"copyLink": "Kopioi linkki",
|
||||
"rotateLink": "Kierrä linkkiä",
|
||||
"disableLink": "Poista linkki käytöstä",
|
||||
"rotateLinkConfirmTitle": "Kierretäänkö vieraslinkkiä?",
|
||||
"rotateLinkDescription": "Nykyinen linkki lakkaa toimimasta välittömästi. Yhdistettyjen SSH-vieraiden yhteys katkaistaan.",
|
||||
"disableLinkConfirmTitle": "Poistetaanko vieraslinkki käytöstä?",
|
||||
"disableLinkDescription": "Anonyymien SSH-katsojien yhteys katkaistaan välittömästi ja nykyinen linkki lakkaa toimimasta.",
|
||||
"linkCopied": "Linkki kopioitu",
|
||||
"roles": "Roolit",
|
||||
"users": "Käyttäjät",
|
||||
"searchUsers": "Hae käyttäjiä",
|
||||
"endConfirmTitle": "Lopetetaanko tämä kokous?",
|
||||
"endDescription": "{{name}} päättyy kaikilta, eikä sitä voida avata uudelleen.",
|
||||
"endPersistentDescription": "Aktiivinen esitys kohdassa {{name}} pysähtyy. Huone pysyy käytettävissä uudelleen.",
|
||||
"deleteRoom": "Poista huone",
|
||||
"deleteConfirmTitle": "Poistetaanko tämä huone?",
|
||||
"deleteDescription": "{{name}} poistetaan pysyvästi kaikilta, eikä sitä voi perua.",
|
||||
"invitedTo": "Sinut kutsuttiin \"{{name}} \" -palveluun",
|
||||
"guest": {
|
||||
"title": "Kokous",
|
||||
"waiting": "Esiintyjää odotellessa...",
|
||||
"linkInvalid": "Tämä vieraslinkki on virheellinen tai kokous on päättynyt"
|
||||
},
|
||||
"reopenFromPanel": "Tämä kokousvälilehti vanheni. Avaa huone uudelleen Kokoukset-paneelista.",
|
||||
"retry": "Yritä uudelleen",
|
||||
"requestControl": "Pyydä hallintaa",
|
||||
"cancelControlRequest": "Peruuta pyyntö",
|
||||
"controlQueue": "Ohjauspyynnöt",
|
||||
"noControlRequests": "Ei vireillä olevia pyyntöjä.",
|
||||
"grantControlTo": "Anna hallinta käyttäjälle {{name}}",
|
||||
"dismissControlRequest": "Hylkää käyttäjän {{name}}hallintapyyntö",
|
||||
"releaseControl": "Vapautusohjaus",
|
||||
"grantControl": "Anna hallinta",
|
||||
"revokeControl": "Ota hallinta takaisin",
|
||||
"controlRequestedBy": "{{name}} pyysi hallintaoikeutta",
|
||||
"grant": "Myöntää",
|
||||
"controlBadge": "Hallinnassa",
|
||||
"youArePresenting": "Esität toisella välilehdellä. Lopeta esitys luovuttaaksesi esiintymispaikan.",
|
||||
"created": "Huone luotu",
|
||||
"invited": "Jäsenet kutsuttuina",
|
||||
"roomTab": "Kokous",
|
||||
"serverChanged": "Kokouspalvelin muuttui. Avaa tämä kokous uudelleen Kokoukset-paneelista.",
|
||||
"hostNotSynced": "Synkronoi tämä isäntä kokouspalvelimeen ennen esityksen aloittamista.",
|
||||
"linkRequiresServer": "Linkitä tämä työpöytä Termix-palvelimeen ja luo kokous siellä jakaaksesi verkkolinkin."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Kokoukset"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Salli istunnon jakaminen",
|
||||
"description": "Salli reaaliaikaisten pääte-, RDP-, VNC- ja Telnet-istuntojen jakaminen koko instanssin kesken. Poistamalla tämän asetuksen käytöstä ohitetaan kaikki isäntäkohtaisesti jakamiseen liittyvät asetukset."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Salli istunnon jakaminen",
|
||||
"description": "Salli live-istuntojen jakaminen tällä isännällä linkin kautta tai muiden käyttäjien kanssa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Jaa istuntoja ja liity huoneisiin",
|
||||
"description": "Jaa live-istuntoja linkin kautta tai muiden käyttäjien kanssa ja luo ja liity yhteistyöhuoneisiin."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Ei koskaan",
|
||||
"oneHour": "1 tunti",
|
||||
"oneDay": "24 tuntia",
|
||||
"sevenDays": "7 päivää",
|
||||
"thirtyDays": "30 päivää",
|
||||
"custom": "Mukautettu"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Jaettu kanssani",
|
||||
"sharedBy": "Jakanut {{username}}",
|
||||
"join": "Liity",
|
||||
"sharedSessionLabel": "{{hostName}} (jaettu)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connexion à la session partagée...",
|
||||
"linkInvalid": "Ce lien de partage est invalide, a expiré ou a été révoqué.",
|
||||
"rateLimited": "Trop de tentatives, veuillez réessayer dans quelques instants.",
|
||||
"sessionEnded": "Cette session est terminée.",
|
||||
"readOnlyBadge": "Affichage uniquement",
|
||||
"ownerLabel": "propriétaire de l'hôte"
|
||||
},
|
||||
"modalTitle": "Session de partage",
|
||||
"shareButton": "Partager",
|
||||
"notReadyToShare": "La session n'est pas encore prête à être partagée.",
|
||||
"modeTab": {
|
||||
"link": "Lien",
|
||||
"user": "Utilisateur"
|
||||
},
|
||||
"linkModeDescription": "Toute personne disposant de ce lien peut s'inscrire, aucun compte n'est requis.",
|
||||
"userModeDescription": "Partagez cette session avec un utilisateur spécifique qui y a déjà accès. S'il n'y a pas encore accès, partagez-lui d'abord l'hôte ou utilisez un lien. Une fois partagée, la session apparaîtra dans son onglet Connexions.",
|
||||
"permissionLevel": {
|
||||
"label": "Niveau de permission",
|
||||
"readOnly": "Lecture seule",
|
||||
"readOnlyDescription": "Je peux suivre la session en direct, mais je ne peux ni écrire ni interagir.",
|
||||
"readWrite": "Lecture-écriture",
|
||||
"readWriteDescription": "Peut saisir du texte et interagir avec la session comme le propriétaire."
|
||||
},
|
||||
"expiryLabel": "Expiration du lien",
|
||||
"createLinkButton": "Créer un lien",
|
||||
"createShareButton": "Partager avec l'utilisateur",
|
||||
"searchUsersPlaceholder": "Rechercher des utilisateurs...",
|
||||
"noUsersFound": "Aucun utilisateur trouvé",
|
||||
"linkCreated": "Lien de partage créé",
|
||||
"linkNeedsServer": "Les liens de partage ne fonctionnent que pour les sessions sur un serveur Termix. Connectez-vous à cet hôte via votre serveur lié pour le partager avec un lien.",
|
||||
"linkCopied": "Lien copié dans le presse-papiers",
|
||||
"copyLink": "Copier le lien",
|
||||
"shareCreated": "Session partagée. Elle apparaîtra dans leur onglet Connexions.",
|
||||
"shareFailed": "Échec de la création du partage",
|
||||
"userLacksHostAccess": "Cet utilisateur n'a pas encore accès à cet hôte. Veuillez d'abord lui partager l'hôte ou utiliser un lien.",
|
||||
"activeShares": "Actions actives",
|
||||
"noActiveShares": "Aucune action active pour cette session",
|
||||
"revoke": "Révoquer",
|
||||
"revokeConfirmTitle": "Révoquer cette action ?",
|
||||
"revokeConfirmDescription": "Toute personne utilisant ce partage perdra immédiatement l'accès.",
|
||||
"revoked": "Action révoquée",
|
||||
"revokeFailed": "Échec de la révocation de l'action",
|
||||
"joinCount": "{{count}} rejoindre",
|
||||
"joinCount_other": "{{count}} rejoint",
|
||||
"expiresAt": "Expire le {{date}}",
|
||||
"linkShareBadge": "Lien",
|
||||
"userShareBadge": "Utilisateur : {{username}}",
|
||||
"loadSharesFailed": "Échec du chargement des actions actives",
|
||||
"remoteDisplayUnavailable": "Le bureau à distance n'est pas disponible pour le moment.",
|
||||
"sharedWithMe": "Séances partagées avec moi",
|
||||
"customHoursPlaceholder": "Heures jusqu'à l'expiration de l'accès"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nouvelle chambre",
|
||||
"roomName": "Nom de la chambre",
|
||||
"persistentRoom": "Chambre persistante",
|
||||
"persistentRoomHint": "Les salles persistantes restent répertoriées après la fin de la réunion et peuvent être réutilisées.",
|
||||
"noRooms": "Aucune salle disponible pour le moment. Créez-en une pour démarrer une réunion.",
|
||||
"openRoom": "Ouvrir",
|
||||
"endRoom": "Fin de la réunion",
|
||||
"leaveRoom": "Partir",
|
||||
"deleteMember": "Retirer",
|
||||
"removeMember": "Supprimer {{name}}",
|
||||
"removeMemberTitle": "Supprimer ce membre ?",
|
||||
"removeMemberDescription": "{{name}} perdra immédiatement l'accès à cette salle. Tout utilisateur SSH actif sera déconnecté.",
|
||||
"invite": "Inviter",
|
||||
"inviteTitle": "Inviter des membres",
|
||||
"members": "Membres",
|
||||
"membersPanel": "Membres de la salle et demandes de contrôle",
|
||||
"membersWithCount": "Membres ({{count}})",
|
||||
"guestsWithCount": "Invités ({{count}})",
|
||||
"removeGuests": "Retirer les invités",
|
||||
"anonymousGuest": "Invité",
|
||||
"online": "en ligne",
|
||||
"offline": "hors ligne",
|
||||
"you": "(toi)",
|
||||
"hostBadge": "Hôte",
|
||||
"presenterBadge": "Présentation",
|
||||
"present": "Présent",
|
||||
"presentTitle": "Montez sur scène",
|
||||
"presentHost": "Hôte",
|
||||
"searchHosts": "Rechercher des hôtes",
|
||||
"noHostsFound": "Aucun hôte disponible trouvé.",
|
||||
"presentProtocol": "Protocole",
|
||||
"stopPresenting": "Arrêtez de présenter",
|
||||
"takeOver": "Reprendre",
|
||||
"takeOverConfirmTitle": "Remplacer la présentation actuelle ?",
|
||||
"takeOverDescription": "{{name}} est en train de présenter. Prendre le relais déconnectera tout le monde de cette scène.",
|
||||
"emptyStage": "Personne ne présente. Montez sur scène pour animer une session.",
|
||||
"stageLoading": "Se connecter à la scène...",
|
||||
"stageEnded": "La présentation s'est terminée.",
|
||||
"roomEnded": "Cette réunion est terminée.",
|
||||
"presenterLabel": "{{name}} présente",
|
||||
"guestLink": "Lien invité",
|
||||
"guestLinkOn": "Le lien invité est activé. Toute personne disposant du lien peut assister à la représentation.",
|
||||
"guestLinkOff": "Le lien invité est désactivé.",
|
||||
"copyLink": "Copier le lien",
|
||||
"rotateLink": "Lien de rotation",
|
||||
"disableLink": "Désactiver le lien",
|
||||
"rotateLinkConfirmTitle": "Faire pivoter le lien invité ?",
|
||||
"rotateLinkDescription": "La connexion actuelle sera immédiatement interrompue. Les sessions SSH virtuelles seront déconnectées.",
|
||||
"disableLinkConfirmTitle": "Désactiver le lien invité ?",
|
||||
"disableLinkDescription": "Les utilisateurs anonymes utilisant SSH seront immédiatement déconnectés et le lien actuel cessera de fonctionner.",
|
||||
"linkCopied": "Lien copié",
|
||||
"roles": "Rôles",
|
||||
"users": "Utilisateurs",
|
||||
"searchUsers": "Utilisateurs de recherche",
|
||||
"endConfirmTitle": "Mettre fin à cette réunion ?",
|
||||
"endDescription": "{{name}} prendra fin pour tout le monde et ne pourra pas être rouvert.",
|
||||
"endPersistentDescription": "La présentation en cours dans {{name}} va s'arrêter. La salle reste disponible pour une réutilisation.",
|
||||
"deleteRoom": "Supprimer la salle",
|
||||
"deleteConfirmTitle": "Supprimer cette pièce ?",
|
||||
"deleteDescription": "{{name}} sera définitivement supprimé pour tout le monde et ne pourra pas être annulé.",
|
||||
"invitedTo": "Vous avez été invité à \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Réunion",
|
||||
"waiting": "En attente du présentateur...",
|
||||
"linkInvalid": "Ce lien invité est invalide ou la réunion est terminée."
|
||||
},
|
||||
"reopenFromPanel": "Cette réunion a expiré. Veuillez rouvrir la salle depuis le panneau Réunions.",
|
||||
"retry": "Réessayer",
|
||||
"requestControl": "Contrôle des demandes",
|
||||
"cancelControlRequest": "Demande d'annulation",
|
||||
"controlQueue": "Demandes de contrôle",
|
||||
"noControlRequests": "Aucune requête en attente.",
|
||||
"grantControlTo": "Donnez le contrôle à {{name}}",
|
||||
"dismissControlRequest": "Rejeter la demande de contrôle de {{name}}",
|
||||
"releaseControl": "Contrôle de la libération",
|
||||
"grantControl": "Donnez le contrôle",
|
||||
"revokeControl": "Reprenez le contrôle",
|
||||
"controlRequestedBy": "{{name}} a demandé le contrôle",
|
||||
"grant": "Accorder",
|
||||
"controlBadge": "En contrôle",
|
||||
"youArePresenting": "Vous présentez dans un autre onglet. Arrêtez votre présentation pour laisser la parole à quelqu'un d'autre.",
|
||||
"created": "Pièce créée",
|
||||
"invited": "Les membres invités",
|
||||
"roomTab": "Réunion",
|
||||
"serverChanged": "Le serveur de réunion a changé. Veuillez rouvrir cette réunion depuis le panneau Réunions.",
|
||||
"hostNotSynced": "Synchronisez cet hôte avec le serveur de réunion avant la présentation.",
|
||||
"linkRequiresServer": "Connectez cet ordinateur de bureau à un serveur Termix et créez-y la réunion pour partager un lien web."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Réunions"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Autoriser le partage de session",
|
||||
"description": "Autorise le partage des sessions de terminal en direct, RDP, VNC et Telnet à l'échelle de l'instance. Remplace toute option de partage par hôte lorsqu'elle est désactivée."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Autoriser le partage de session",
|
||||
"description": "Autoriser le partage des sessions en direct sur cet hébergeur via un lien ou avec d'autres utilisateurs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Partagez des sessions et rejoignez des salles",
|
||||
"description": "Partagez les sessions en direct par lien ou avec d'autres utilisateurs, et créez et rejoignez des espaces de collaboration."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Jamais",
|
||||
"oneHour": "1 heure",
|
||||
"oneDay": "24 heures",
|
||||
"sevenDays": "7 jours",
|
||||
"thirtyDays": "30 jours",
|
||||
"custom": "Personnalisé"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Partagé avec moi",
|
||||
"sharedBy": "Partagé par {{username}}",
|
||||
"join": "Rejoindre",
|
||||
"sharedSessionLabel": "{{hostName}} (partagé)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "מתחבר לסשן משותף...",
|
||||
"linkInvalid": "קישור השיתוף הזה אינו תקין, פג תוקף או בוטל",
|
||||
"rateLimited": "יותר מדי ניסיונות, אנא נסה שוב בקרוב",
|
||||
"sessionEnded": "מושב זה הסתיים",
|
||||
"readOnlyBadge": "צפייה בלבד",
|
||||
"ownerLabel": "בעל המארח"
|
||||
},
|
||||
"modalTitle": "שיתוף סשן",
|
||||
"shareButton": "לַחֲלוֹק",
|
||||
"notReadyToShare": "הסשן עדיין לא מוכן לשיתוף",
|
||||
"modeTab": {
|
||||
"link": "לְקַשֵׁר",
|
||||
"user": "מִשׁתַמֵשׁ"
|
||||
},
|
||||
"linkModeDescription": "כל מי שיש לו קישור זה יכול להצטרף, אין צורך בחשבון.",
|
||||
"userModeDescription": "שתף עם משתמש ספציפי שכבר יש לו גישה למארח זה. אם עדיין אין לו גישה, שתף איתו תחילה את המארח או השתמש בקישור במקום זאת. לאחר השיתוף, הסשן מופיע בכרטיסייה \"חיבורים\" שלו.",
|
||||
"permissionLevel": {
|
||||
"label": "רמת הרשאה",
|
||||
"readOnly": "לקריאה בלבד",
|
||||
"readOnlyDescription": "יכול לצפות בפגישה בשידור חי אך לא להקליד או לתקשר.",
|
||||
"readWrite": "קריאה-כתיבה",
|
||||
"readWriteDescription": "יכול להקליד ולקיים אינטראקציה עם הסשן בדיוק כמו הבעלים."
|
||||
},
|
||||
"expiryLabel": "תוקף הקישור",
|
||||
"createLinkButton": "צור קישור",
|
||||
"createShareButton": "שתף עם המשתמש",
|
||||
"searchUsersPlaceholder": "חיפוש משתמשים...",
|
||||
"noUsersFound": "לא נמצאו משתמשים",
|
||||
"linkCreated": "קישור השיתוף נוצר",
|
||||
"linkNeedsServer": "קישורי שיתוף פועלים רק עבור סשנים בשרת Termix. חבר מארח זה דרך השרת המקושר שלך כדי לשתף אותו עם קישור.",
|
||||
"linkCopied": "קישור שהועתק ללוח",
|
||||
"copyLink": "העתקת קישור",
|
||||
"shareCreated": "הסשן שותף. זה יופיע בלשונית החיבורים שלהם.",
|
||||
"shareFailed": "יצירת השיתוף נכשלה",
|
||||
"userLacksHostAccess": "למשתמש הזה עדיין אין גישה למארח הזה. שתף את המארח איתו תחילה, או השתמש בקישור במקום זאת.",
|
||||
"activeShares": "מניות פעילות",
|
||||
"noActiveShares": "אין שיתופים פעילים עבור סשן זה",
|
||||
"revoke": "לְבַטֵל",
|
||||
"revokeConfirmTitle": "לבטל את השיתוף הזה?",
|
||||
"revokeConfirmDescription": "כל מי שישתמש בשיתוף הזה יאבד גישה באופן מיידי.",
|
||||
"revoked": "השיתוף בוטל",
|
||||
"revokeFailed": "ביטול השיתוף נכשל",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} מצטרף",
|
||||
"expiresAt": "פג תוקף {{date}}",
|
||||
"linkShareBadge": "לְקַשֵׁר",
|
||||
"userShareBadge": "משתמש: {{username}}",
|
||||
"loadSharesFailed": "נכשלה טעינת השיתופים הפעילים",
|
||||
"remoteDisplayUnavailable": "שולחן עבודה מרוחק אינו זמין כרגע.",
|
||||
"sharedWithMe": "מפגשים ששותפו איתי",
|
||||
"customHoursPlaceholder": "שעות עד לפקיעת הגישה"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "חדר חדש",
|
||||
"roomName": "שם החדר",
|
||||
"persistentRoom": "חדר קבוע",
|
||||
"persistentRoomHint": "חדרים קבועים נשארים רשומים לאחר סיום הפגישה וניתן לעשות בהם שימוש חוזר.",
|
||||
"noRooms": "אין עדיין חדרים. צור אחד כדי להתחיל פגישה.",
|
||||
"openRoom": "לִפְתוֹחַ",
|
||||
"endRoom": "סיום פגישה",
|
||||
"leaveRoom": "לַעֲזוֹב",
|
||||
"deleteMember": "לְהַסִיר",
|
||||
"removeMember": "הסר {{name}}",
|
||||
"removeMemberTitle": "להסיר את החבר הזה?",
|
||||
"removeMemberDescription": "{{name}} יאבד גישה לחדר הזה באופן מיידי. כל צופה פעיל ב-SSH ינותק.",
|
||||
"invite": "לְהַזמִין",
|
||||
"inviteTitle": "הזמנת חברים",
|
||||
"members": "חברים",
|
||||
"membersPanel": "חברי חדר ובקשות בקרה",
|
||||
"membersWithCount": "חברים ({{count}})",
|
||||
"guestsWithCount": "אורחים ({{count}})",
|
||||
"removeGuests": "הסר אורחים",
|
||||
"anonymousGuest": "אוֹרֵחַ",
|
||||
"online": "באינטרנט",
|
||||
"offline": "לא מקוון",
|
||||
"you": "(אַתָה)",
|
||||
"hostBadge": "מְאָרֵחַ",
|
||||
"presenterBadge": "הצגה",
|
||||
"present": "לְהַצִיג",
|
||||
"presentTitle": "עלו על הבמה",
|
||||
"presentHost": "מְאָרֵחַ",
|
||||
"searchHosts": "חיפוש מארחים",
|
||||
"noHostsFound": "לא נמצאו מארחים זמינים.",
|
||||
"presentProtocol": "פּרוֹטוֹקוֹל",
|
||||
"stopPresenting": "הפסקת ההצגה",
|
||||
"takeOver": "השתלטות",
|
||||
"takeOverConfirmTitle": "להחליף את המצגת הנוכחית?",
|
||||
"takeOverDescription": "{{name}} מציג כעת. השתלטות תנתק את כולם מהבמה הזו.",
|
||||
"emptyStage": "אף אחד לא מציג. עלו לבמה כדי לשתף סשן.",
|
||||
"stageLoading": "מתחבר לבמה...",
|
||||
"stageEnded": "המצגת הסתיימה",
|
||||
"roomEnded": "פגישה זו הסתיימה",
|
||||
"presenterLabel": "{{name}} מציג",
|
||||
"guestLink": "קישור אורח",
|
||||
"guestLinkOn": "קישור האורח פעיל. כל מי שיש לו קישור יכול לצפות בבמה.",
|
||||
"guestLinkOff": "קישור האורח כבוי.",
|
||||
"copyLink": "העתקת קישור",
|
||||
"rotateLink": "סיבוב קישור",
|
||||
"disableLink": "השבת קישור",
|
||||
"rotateLinkConfirmTitle": "לסובב את קישור האורח?",
|
||||
"rotateLinkDescription": "הקישור הנוכחי יפסיק לפעול באופן מיידי. אורחי SSH המחוברים ינותקו.",
|
||||
"disableLinkConfirmTitle": "להשבית את קישור האורח?",
|
||||
"disableLinkDescription": "צופי SSH אנונימיים ינותקו באופן מיידי והקישור הנוכחי יפסיק לפעול.",
|
||||
"linkCopied": "הקישור הועתק",
|
||||
"roles": "תפקידים",
|
||||
"users": "משתמשים",
|
||||
"searchUsers": "חיפוש משתמשים",
|
||||
"endConfirmTitle": "לסיים את הפגישה הזו?",
|
||||
"endDescription": "{{name}} יסתיים עבור כולם ולא ניתן יהיה לפתוח אותו מחדש.",
|
||||
"endPersistentDescription": "המצגת הפעילה ב- {{name}} תיעצר. החדר יישאר זמין לשימוש חוזר.",
|
||||
"deleteRoom": "מחיקת חדר",
|
||||
"deleteConfirmTitle": "למחוק את החדר הזה?",
|
||||
"deleteDescription": "{{name}} יימחק לצמיתות עבור כולם ולא ניתן יהיה לבטל אותו.",
|
||||
"invitedTo": "הוזמנת ל-\"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "פְּגִישָׁה",
|
||||
"waiting": "מחכה למנחה...",
|
||||
"linkInvalid": "קישור האורח הזה אינו תקין או שהפגישה הסתיימה"
|
||||
},
|
||||
"reopenFromPanel": "פג תוקף כרטיסיית הפגישה הזו. פתח מחדש את החדר מחלונית הפגישות.",
|
||||
"retry": "נסה שוב",
|
||||
"requestControl": "בקרת בקשות",
|
||||
"cancelControlRequest": "ביטול בקשה",
|
||||
"controlQueue": "בקשות בקרה",
|
||||
"noControlRequests": "אין בקשות ממתינות.",
|
||||
"grantControlTo": "תן שליטה ל- {{name}}",
|
||||
"dismissControlRequest": "סגור את בקשת הבקרה של {{name}}",
|
||||
"releaseControl": "בקרת שחרור",
|
||||
"grantControl": "תן שליטה",
|
||||
"revokeControl": "קח בחזרה את השליטה",
|
||||
"controlRequestedBy": "{{name}} ביקש שליטה",
|
||||
"grant": "מַעֲנָק",
|
||||
"controlBadge": "בשליטה",
|
||||
"youArePresenting": "אתה מציג בכרטיסייה אחרת. הפסק את ההצגה כדי להעביר את הבמה.",
|
||||
"created": "החדר נוצר",
|
||||
"invited": "חברים שהוזמנו",
|
||||
"roomTab": "פְּגִישָׁה",
|
||||
"serverChanged": "שרת הפגישות השתנה. פתח מחדש פגישה זו מחלונית הפגישות.",
|
||||
"hostNotSynced": "סנכרן מארח זה עם שרת הפגישה לפני ההצגה.",
|
||||
"linkRequiresServer": "קשר שולחן עבודה זה לשרת Termix וצור שם את הפגישה כדי לשתף קישור אינטרנט."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "פגישות"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "אפשר שיתוף סשנים",
|
||||
"description": "מאפשר שיתוף של הפעלות מסוף חי, RDP, VNC ו-Telnet בכל המופעים. מבטל כל אפשרות שיתוף לפי מארח כאשר היא מושבתת."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "אפשר שיתוף סשנים",
|
||||
"description": "אפשר שיתוף של מפגשים חיים במארח זה באמצעות קישור או עם משתמשים אחרים"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "שתף מפגשים והצטרף לחדרים",
|
||||
"description": "שתף מפגשים חיים באמצעות קישור או עם משתמשים אחרים, וצר והצטרף לחדרי שיתוף פעולה."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "לְעוֹלָם לֹא",
|
||||
"oneHour": "שעה אחת",
|
||||
"oneDay": "24 שעות",
|
||||
"sevenDays": "7 ימים",
|
||||
"thirtyDays": "30 ימים",
|
||||
"custom": "מִנְהָג"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "שיתף איתי",
|
||||
"sharedBy": "שותף על ידי {{username}}",
|
||||
"join": "לְהִצְטַרֵף",
|
||||
"sharedSessionLabel": "{{hostName}} (שותף)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "साझा करें",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "उपयोगकर्ता"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "अनुमति स्तर",
|
||||
"readOnly": "केवल-पठनीय",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "निरस्त करें",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "{{date}} को समाप्त होगा",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "पहुँच समाप्त होने तक के घंटे"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "खोलें",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "हटाएं",
|
||||
"removeMember": "{{name}} हटाएँ",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "ऑनलाइन",
|
||||
"offline": "ऑफलाइन",
|
||||
"you": "(you)",
|
||||
"hostBadge": "होस्ट",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "होस्ट",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "प्रोटोकॉल",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "भूमिकाएं",
|
||||
"users": "उपयोगकर्ता",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "कभी नहीं",
|
||||
"oneHour": "1 घंटा",
|
||||
"oneDay": "24 घंटे",
|
||||
"sevenDays": "7 दिन",
|
||||
"thirtyDays": "30 दिन",
|
||||
"custom": "कस्टम"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Kapcsolódás a megosztott munkamenethez...",
|
||||
"linkInvalid": "Ez a megosztási link érvénytelen, lejárt vagy visszavonták.",
|
||||
"rateLimited": "Túl sok próbálkozás, kérjük, próbáld újra később",
|
||||
"sessionEnded": "Ez a munkamenet véget ért",
|
||||
"readOnlyBadge": "Csak megtekintés",
|
||||
"ownerLabel": "Házigazda tulajdonos"
|
||||
},
|
||||
"modalTitle": "Megosztási munkamenet",
|
||||
"shareButton": "Részesedés",
|
||||
"notReadyToShare": "A munkamenet még nem megosztható",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Felhasználó"
|
||||
},
|
||||
"linkModeDescription": "Bárki csatlakozhat a link birtokában, nincs szükség regisztrációra.",
|
||||
"userModeDescription": "Megosztás egy adott felhasználóval, aki már rendelkezik hozzáféréssel ehhez a gazdagéphez. Ha még nincs hozzáférésük, először oszd meg vele a gazdagépet, vagy használj hivatkozást. A megosztás után a munkamenet megjelenik a Kapcsolatok lapon.",
|
||||
"permissionLevel": {
|
||||
"label": "Jogosultsági szint",
|
||||
"readOnly": "Csak olvasható",
|
||||
"readOnlyDescription": "Élőben nézhetem az előadást, de nem gépelhetek és nem léphetek interakcióba.",
|
||||
"readWrite": "Olvasható-írható",
|
||||
"readWriteDescription": "Pont úgy tud gépelni és kommunikálni a munkamenettel, mint a tulajdonos."
|
||||
},
|
||||
"expiryLabel": "Link lejárata",
|
||||
"createLinkButton": "Link létrehozása",
|
||||
"createShareButton": "Megosztás a felhasználóval",
|
||||
"searchUsersPlaceholder": "Felhasználók keresése...",
|
||||
"noUsersFound": "Nem található felhasználó",
|
||||
"linkCreated": "Megosztási link létrehozva",
|
||||
"linkNeedsServer": "A megosztási linkek csak Termix szerveren lévő munkamenetek esetén működnek. A linkkel való megosztáshoz csatlakoztasd ezt a hosztot a csatolt szervereden keresztül.",
|
||||
"linkCopied": "Link a vágólapra másolva",
|
||||
"copyLink": "Link másolása",
|
||||
"shareCreated": "Munkamenet megosztva. Megjelenik a Kapcsolatok fülön.",
|
||||
"shareFailed": "Nem sikerült létrehozni a megosztást",
|
||||
"userLacksHostAccess": "A felhasználónak még nincs hozzáférése ehhez a gazdagéphez. Először oszd meg vele a gazdagépet, vagy használj linket.",
|
||||
"activeShares": "Aktív megosztások",
|
||||
"noActiveShares": "Nincsenek aktív megosztások ebben a munkamenetben",
|
||||
"revoke": "Visszavonás",
|
||||
"revokeConfirmTitle": "Visszavonja ezt a megosztást?",
|
||||
"revokeConfirmDescription": "Bárki, aki ezt a megosztást használja, azonnal elveszíti a hozzáférését.",
|
||||
"revoked": "Megosztás visszavonva",
|
||||
"revokeFailed": "Nem sikerült visszavonni a megosztást",
|
||||
"joinCount": "{{count}} csatlakozás",
|
||||
"joinCount_other": "{{count}} csatlakozások",
|
||||
"expiresAt": "Lejár {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "Felhasználó: {{username}}",
|
||||
"loadSharesFailed": "Nem sikerült betölteni az aktív megosztásokat",
|
||||
"remoteDisplayUnavailable": "A távoli asztal jelenleg nem érhető el.",
|
||||
"sharedWithMe": "Velem megosztott munkamenetek",
|
||||
"customHoursPlaceholder": "Órák a hozzáférés lejártáig"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Új szoba",
|
||||
"roomName": "Szoba neve",
|
||||
"persistentRoom": "Állandó szoba",
|
||||
"persistentRoomHint": "Az állandó szobák a megbeszélés befejezése után is listázva maradnak, és újra felhasználhatók.",
|
||||
"noRooms": "Még nincsenek szobák. Hozz létre egyet a megbeszélés megkezdéséhez.",
|
||||
"openRoom": "Nyitott",
|
||||
"endRoom": "Megbeszélés vége",
|
||||
"leaveRoom": "Szabadság",
|
||||
"deleteMember": "Eltávolítás",
|
||||
"removeMember": "Eltávolítás {{name}}",
|
||||
"removeMemberTitle": "Eltávolítja ezt a tagot?",
|
||||
"removeMemberDescription": "{{name}} azonnal elveszíti a hozzáférést ehhez a szobához. Minden aktív SSH-megjelenítő lekapcsolódik.",
|
||||
"invite": "Meghív",
|
||||
"inviteTitle": "Tagok meghívása",
|
||||
"members": "Tagok",
|
||||
"membersPanel": "Szoba tagjai és irányítási kérelmek",
|
||||
"membersWithCount": "Tagok ({{count}})",
|
||||
"guestsWithCount": "Vendégek ({{count}})",
|
||||
"removeGuests": "Vendégek eltávolítása",
|
||||
"anonymousGuest": "Vendég",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(te)",
|
||||
"hostBadge": "Házigazda",
|
||||
"presenterBadge": "Bemutatja",
|
||||
"present": "Jelenlegi",
|
||||
"presentTitle": "Lépj színpadra",
|
||||
"presentHost": "Házigazda",
|
||||
"searchHosts": "Keresés hosztok",
|
||||
"noHostsFound": "Nem található elérhető host.",
|
||||
"presentProtocol": "Jegyzőkönyv",
|
||||
"stopPresenting": "Prezentáció leállítása",
|
||||
"takeOver": "Átvétel",
|
||||
"takeOverConfirmTitle": "Lecseréli a jelenlegi prezentációt?",
|
||||
"takeOverDescription": "{{name}} most tart előadást. Az irányítás átvétele mindenkit lekapcsolna arról a színpadról.",
|
||||
"emptyStage": "Senki sem tart előadást. Lépjen színpadra, és ossza meg velünk az előadást.",
|
||||
"stageLoading": "Csatlakozás a színpadhoz...",
|
||||
"stageEnded": "A prezentáció véget ért",
|
||||
"roomEnded": "Ez a találkozó véget ért",
|
||||
"presenterLabel": "{{name}} bemutatja",
|
||||
"guestLink": "Vendéglink",
|
||||
"guestLinkOn": "Vendéglink be van kapcsolva. Bárki, aki rendelkezik a linkkel, nézheti a színpadot.",
|
||||
"guestLinkOff": "A vendéglink ki van kapcsolva.",
|
||||
"copyLink": "Link másolása",
|
||||
"rotateLink": "Link elforgatása",
|
||||
"disableLink": "Link letiltása",
|
||||
"rotateLinkConfirmTitle": "Vendéglink elforgatása?",
|
||||
"rotateLinkDescription": "A jelenlegi kapcsolat azonnal leáll. A csatlakoztatott SSH-vendégek le lesznek választva.",
|
||||
"disableLinkConfirmTitle": "Letiltja a vendéglinket?",
|
||||
"disableLinkDescription": "A névtelen SSH-nézők azonnal le lesznek választva, és a jelenlegi link nem fog működni.",
|
||||
"linkCopied": "Link másolva",
|
||||
"roles": "Szerepkörök",
|
||||
"users": "Felhasználók",
|
||||
"searchUsers": "Felhasználók keresése",
|
||||
"endConfirmTitle": "Befejezi ezt a megbeszélést?",
|
||||
"endDescription": "{{name}} mindenki számára megszűnik, és nem lehet újra megnyitni.",
|
||||
"endPersistentDescription": "A {{name}} teremben futó aktív prezentáció leáll. A szoba továbbra is elérhető marad újrafelhasználásra.",
|
||||
"deleteRoom": "Szoba törlése",
|
||||
"deleteConfirmTitle": "Törli ezt a szobát?",
|
||||
"deleteDescription": "A {{name}} véglegesen törlésre kerül mindenki számára, és a művelet nem vonható vissza.",
|
||||
"invitedTo": "Meghívtak a következőre: \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Találkozó",
|
||||
"waiting": "Várjuk az előadót...",
|
||||
"linkInvalid": "Ez a vendéglink érvénytelen, vagy a megbeszélés véget ért."
|
||||
},
|
||||
"reopenFromPanel": "Ez a megbeszélésfül lejárt. Nyissa meg újra a szobát a Megbeszélések panelről.",
|
||||
"retry": "Újrapróbálkozás",
|
||||
"requestControl": "Kérelemvezérlés",
|
||||
"cancelControlRequest": "Kérés visszavonása",
|
||||
"controlQueue": "Ellenőrzési kérelmek",
|
||||
"noControlRequests": "Nincsenek függőben lévő kérések.",
|
||||
"grantControlTo": "Átadni az irányítást {{name}}-nak",
|
||||
"dismissControlRequest": "Elutasítom {{name}}vezérlési kérelmét",
|
||||
"releaseControl": "Kioldásvezérlés",
|
||||
"grantControl": "Átadni az irányítást",
|
||||
"revokeControl": "Vedd vissza az irányítást",
|
||||
"controlRequestedBy": "{{name}} kérte az irányítást",
|
||||
"grant": "Támogatás",
|
||||
"controlBadge": "Irányítás alatt",
|
||||
"youArePresenting": "Egy másik lapon tartasz előadást. Állítsa le az előadást, hogy átadhassa a helyet.",
|
||||
"created": "Szoba létrehozva",
|
||||
"invited": "Meghívott tagok",
|
||||
"roomTab": "Találkozó",
|
||||
"serverChanged": "A megbeszélés szervere megváltozott. Nyissa meg újra a megbeszélést a Megbeszélések panelről.",
|
||||
"hostNotSynced": "Szinkronizálja ezt a gazdagépet a megbeszélés szerverével a prezentáció megkezdése előtt.",
|
||||
"linkRequiresServer": "Kapcsold össze ezt az asztalt egy Termix szerverrel, és hozd létre ott a találkozót egy webhivatkozás megosztásához."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Találkozók"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Munkamenet-megosztás engedélyezése",
|
||||
"description": "Engedélyezi az élő terminál-, RDP-, VNC- és Telnet-munkamenetek példányonkénti megosztását. Letiltás esetén felülírja az összes gazdagépenkénti megosztási kapcsolót."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Munkamenet-megosztás engedélyezése",
|
||||
"description": "Engedélyezi az élő munkamenetek megosztását ezen a gazdagépen linken vagy más felhasználókkal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Munkamenetek megosztása és szobákhoz való csatlakozás",
|
||||
"description": "Ossz meg élő munkameneteket linken keresztül vagy más felhasználókkal, és hozz létre, illetve csatlakozz együttműködési szobákhoz."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Soha",
|
||||
"oneHour": "1 óra",
|
||||
"oneDay": "24 óra",
|
||||
"sevenDays": "7 nap",
|
||||
"thirtyDays": "30 nap",
|
||||
"custom": "Szokás"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Megosztva velem",
|
||||
"sharedBy": "Megosztotta: {{username}}",
|
||||
"join": "Csatlakozik",
|
||||
"sharedSessionLabel": "{{hostName}} (megosztva)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Pengguna"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Dibagikan oleh {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connessione alla sessione condivisa in corso...",
|
||||
"linkInvalid": "Questo link di condivisione non è valido, è scaduto o è stato revocato.",
|
||||
"rateLimited": "Troppi tentativi, riprova tra poco.",
|
||||
"sessionEnded": "Questa sessione è terminata",
|
||||
"readOnlyBadge": "Solo visualizzazione",
|
||||
"ownerLabel": "Proprietario dell'host"
|
||||
},
|
||||
"modalTitle": "Sessione di condivisione",
|
||||
"shareButton": "Condividi",
|
||||
"notReadyToShare": "La sessione non è ancora pronta per la condivisione.",
|
||||
"modeTab": {
|
||||
"link": "Collegamento",
|
||||
"user": "Utente"
|
||||
},
|
||||
"linkModeDescription": "Chiunque abbia questo link può partecipare, non è necessario creare un account.",
|
||||
"userModeDescription": "Condividi la sessione con un utente specifico che ha già accesso a questo host. Se non ha ancora accesso, condividi prima l'host con lui oppure usa un link. Una volta condivisa, la sessione apparirà nella sua scheda Connessioni.",
|
||||
"permissionLevel": {
|
||||
"label": "Livello di autorizzazione",
|
||||
"readOnly": "Sola lettura",
|
||||
"readOnlyDescription": "È possibile seguire la sessione in diretta, ma non è possibile digitare o interagire.",
|
||||
"readWrite": "lettura-scrittura",
|
||||
"readWriteDescription": "Può digitare e interagire con la sessione proprio come il proprietario."
|
||||
},
|
||||
"expiryLabel": "Scadenza del collegamento",
|
||||
"createLinkButton": "Crea collegamento",
|
||||
"createShareButton": "Condividi con l'utente",
|
||||
"searchUsersPlaceholder": "Cerca utenti...",
|
||||
"noUsersFound": "Nessun utente trovato",
|
||||
"linkCreated": "Link di condivisione creato",
|
||||
"linkNeedsServer": "I link di condivisione funzionano solo per le sessioni su un server Termix. Collega questo host tramite il tuo server collegato per condividerlo con un link.",
|
||||
"linkCopied": "Link copiato negli appunti",
|
||||
"copyLink": "Copia il link",
|
||||
"shareCreated": "Sessione condivisa. Sarà visibile nella scheda Connessioni.",
|
||||
"shareFailed": "Impossibile creare la condivisione",
|
||||
"userLacksHostAccess": "Quell'utente non ha ancora accesso a questo host. Condividi prima l'host con lui, oppure usa un link.",
|
||||
"activeShares": "Condivisioni attive",
|
||||
"noActiveShares": "Nessuna azione attiva in questa sessione.",
|
||||
"revoke": "Revoca",
|
||||
"revokeConfirmTitle": "Revocare questa condivisione?",
|
||||
"revokeConfirmDescription": "Chiunque utilizzi questa condivisione perderà immediatamente l'accesso.",
|
||||
"revoked": "Azione revocata.",
|
||||
"revokeFailed": "Impossibile revocare la quota",
|
||||
"joinCount": "{{count}} unisciti",
|
||||
"joinCount_other": "{{count}} si unisce",
|
||||
"expiresAt": "Scade il {{date}}",
|
||||
"linkShareBadge": "Collegamento",
|
||||
"userShareBadge": "Utente: {{username}}",
|
||||
"loadSharesFailed": "Impossibile caricare le azioni attive",
|
||||
"remoteDisplayUnavailable": "Al momento il desktop remoto non è disponibile.",
|
||||
"sharedWithMe": "Sessioni condivise con me",
|
||||
"customHoursPlaceholder": "Ore fino alla scadenza dell'accesso"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nuova stanza",
|
||||
"roomName": "Nome della stanza",
|
||||
"persistentRoom": "Stanza persistente",
|
||||
"persistentRoomHint": "Le stanze persistenti rimangono elencate anche dopo la fine della riunione e possono essere riutilizzate.",
|
||||
"noRooms": "Non ci sono ancora stanze. Creane una per avviare una riunione.",
|
||||
"openRoom": "Apri",
|
||||
"endRoom": "Fine della riunione",
|
||||
"leaveRoom": "Partire",
|
||||
"deleteMember": "Rimuovi",
|
||||
"removeMember": "Rimuovi {{name}}",
|
||||
"removeMemberTitle": "Rimuovere questo membro?",
|
||||
"removeMemberDescription": "{{name}} perderà immediatamente l'accesso a questa stanza. Qualsiasi visualizzatore SSH attivo verrà disconnesso.",
|
||||
"invite": "Invitare",
|
||||
"inviteTitle": "Invita i membri",
|
||||
"members": "Membri",
|
||||
"membersPanel": "Membri della stanza e richieste di controllo",
|
||||
"membersWithCount": "Membri ({{count}})",
|
||||
"guestsWithCount": "Ospiti ({{count}})",
|
||||
"removeGuests": "Rimuovi gli ospiti",
|
||||
"anonymousGuest": "Ospite",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(Voi)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presentazione",
|
||||
"present": "Presente",
|
||||
"presentTitle": "Sali sul palco",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Cerca host",
|
||||
"noHostsFound": "Nessun host disponibile trovato.",
|
||||
"presentProtocol": "Protocollo",
|
||||
"stopPresenting": "Smetti di presentare",
|
||||
"takeOver": "Rilevare",
|
||||
"takeOverConfirmTitle": "Sostituire la presentazione attuale?",
|
||||
"takeOverDescription": "{{name}} sta presentando ora. Prendere il controllo disconnetterà tutti da quel palco.",
|
||||
"emptyStage": "Nessuno sta presentando. Salite sul palco per condividere una sessione.",
|
||||
"stageLoading": "Collegamento al palco...",
|
||||
"stageEnded": "La presentazione è terminata",
|
||||
"roomEnded": "La riunione è terminata",
|
||||
"presenterLabel": "{{name}} sta presentando",
|
||||
"guestLink": "Link ospite",
|
||||
"guestLinkOn": "Il link per gli ospiti è attivo. Chiunque abbia il link può assistere alla diretta.",
|
||||
"guestLinkOff": "Il link per gli ospiti è disattivato.",
|
||||
"copyLink": "Copia il link",
|
||||
"rotateLink": "Ruota il collegamento",
|
||||
"disableLink": "Disabilita il collegamento",
|
||||
"rotateLinkConfirmTitle": "Ruotare il link per gli ospiti?",
|
||||
"rotateLinkDescription": "Il collegamento attuale smetterà di funzionare immediatamente. Gli ospiti SSH connessi verranno disconnessi.",
|
||||
"disableLinkConfirmTitle": "Disabilitare il link per gli ospiti?",
|
||||
"disableLinkDescription": "Gli utenti che tentano di connettersi tramite SSH in modo anonimo verranno disconnessi immediatamente e il collegamento corrente smetterà di funzionare.",
|
||||
"linkCopied": "Collegamento copiato",
|
||||
"roles": "Ruoli",
|
||||
"users": "Utenti",
|
||||
"searchUsers": "Utenti di ricerca",
|
||||
"endConfirmTitle": "Chiudere questa riunione?",
|
||||
"endDescription": "{{name}} terminerà per tutti e non potrà essere riaperto.",
|
||||
"endPersistentDescription": "La presentazione attiva in {{name}} verrà interrotta. La sala rimane disponibile per un altro utilizzo.",
|
||||
"deleteRoom": "Eliminare la stanza",
|
||||
"deleteConfirmTitle": "Eliminare questa stanza?",
|
||||
"deleteDescription": "{{name}} verrà eliminato definitivamente per tutti e non sarà possibile annullare l'operazione.",
|
||||
"invitedTo": "Sei stato invitato a \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Incontro",
|
||||
"waiting": "In attesa del presentatore...",
|
||||
"linkInvalid": "Questo link per gli ospiti non è valido oppure la riunione è terminata."
|
||||
},
|
||||
"reopenFromPanel": "La scheda di questa riunione è scaduta. Riapri la sala riunioni dal pannello Riunioni.",
|
||||
"retry": "Riprova",
|
||||
"requestControl": "Controllo della richiesta",
|
||||
"cancelControlRequest": "Annulla richiesta",
|
||||
"controlQueue": "Richieste di controllo",
|
||||
"noControlRequests": "Nessuna richiesta in sospeso.",
|
||||
"grantControlTo": "Assegna il controllo a {{name}}",
|
||||
"dismissControlRequest": "Ignora la richiesta di controllo di {{name}}",
|
||||
"releaseControl": "Controllo del rilascio",
|
||||
"grantControl": "Dare il controllo",
|
||||
"revokeControl": "Riprendi il controllo",
|
||||
"controlRequestedBy": "{{name}} ha chiesto il controllo",
|
||||
"grant": "Concessione",
|
||||
"controlBadge": "In controllo",
|
||||
"youArePresenting": "Stai presentando in un'altra scheda. Interrompi la presentazione per cedere la parola.",
|
||||
"created": "Stanza creata",
|
||||
"invited": "Membri invitati",
|
||||
"roomTab": "Incontro",
|
||||
"serverChanged": "Il server della riunione è cambiato. Riapri la riunione dal pannello Riunioni.",
|
||||
"hostNotSynced": "Sincronizza l'host con il server della riunione prima di iniziare la presentazione.",
|
||||
"linkRequiresServer": "Collega questo computer desktop a un server Termix e crea la riunione lì per condividere un link web."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Riunioni"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Consenti la condivisione della sessione",
|
||||
"description": "Consente la condivisione di sessioni live di terminale, RDP, VNC e Telnet a livello di istanza. Quando disabilitata, questa opzione sovrascrive tutte le impostazioni di condivisione per singolo host."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Consenti la condivisione della sessione",
|
||||
"description": "Consenti la condivisione delle sessioni live su questo host tramite link o con altri utenti"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Condividi sessioni e unisciti alle stanze",
|
||||
"description": "Condividi sessioni live tramite link o con altri utenti, e crea e partecipa a stanze di collaborazione."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Mai",
|
||||
"oneHour": "1 ora",
|
||||
"oneDay": "24 ore",
|
||||
"sevenDays": "7 giorni",
|
||||
"thirtyDays": "30 giorni",
|
||||
"custom": "Personalizzato"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Condiviso con me",
|
||||
"sharedBy": "Condiviso da {{username}}",
|
||||
"join": "Giuntura",
|
||||
"sharedSessionLabel": "{{hostName}} (condiviso)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "共有セッションに接続しています...",
|
||||
"linkInvalid": "この共有リンクは無効、期限切れ、または取り消されています。",
|
||||
"rateLimited": "試行回数が多すぎます。しばらくしてからもう一度お試しください。",
|
||||
"sessionEnded": "このセッションは終了しました",
|
||||
"readOnlyBadge": "閲覧のみ",
|
||||
"ownerLabel": "ホストオーナー"
|
||||
},
|
||||
"modalTitle": "セッションを共有する",
|
||||
"shareButton": "共有",
|
||||
"notReadyToShare": "セッションはまだ共有できません",
|
||||
"modeTab": {
|
||||
"link": "リンク",
|
||||
"user": "ユーザー"
|
||||
},
|
||||
"linkModeDescription": "このリンクを知っている人なら誰でも参加できます。アカウントは必要ありません。",
|
||||
"userModeDescription": "このホストへのアクセス権を既に持っている特定のユーザーと共有します。まだアクセス権がない場合は、まずホストを共有するか、代わりにリンクを使用してください。共有が完了すると、セッションはユーザーの「接続」タブに表示されます。",
|
||||
"permissionLevel": {
|
||||
"label": "権限レベル",
|
||||
"readOnly": "読み取り専用",
|
||||
"readOnlyDescription": "セッションをライブで視聴することはできますが、文字入力やインタラクションはできません。",
|
||||
"readWrite": "読み書き",
|
||||
"readWriteDescription": "所有者と同様に、セッション内で文字を入力したり操作したりできます。"
|
||||
},
|
||||
"expiryLabel": "リンクの有効期限",
|
||||
"createLinkButton": "リンクを作成する",
|
||||
"createShareButton": "ユーザーと共有する",
|
||||
"searchUsersPlaceholder": "ユーザーを検索...",
|
||||
"noUsersFound": "ユーザーが見つかりませんでした",
|
||||
"linkCreated": "共有リンクが作成されました",
|
||||
"linkNeedsServer": "共有リンクは、Termixサーバー上のセッションでのみ機能します。リンクを使用してホストを共有するには、リンク先のサーバー経由でこのホストに接続してください。",
|
||||
"linkCopied": "リンクがクリップボードにコピーされました",
|
||||
"copyLink": "リンクをコピー",
|
||||
"shareCreated": "セッションが共有されました。相手の「接続」タブに表示されます。",
|
||||
"shareFailed": "共有の作成に失敗しました",
|
||||
"userLacksHostAccess": "そのユーザーはまだこのホストへのアクセス権を持っていません。まずホストを共有するか、代わりにリンクを使用してください。",
|
||||
"activeShares": "アクティブなシェア",
|
||||
"noActiveShares": "今セッションで有効な株式はありません",
|
||||
"revoke": "無効化",
|
||||
"revokeConfirmTitle": "この共有を取り消しますか?",
|
||||
"revokeConfirmDescription": "この共有ファイルを使用している人は、即座にアクセス権を失います。",
|
||||
"revoked": "シェアが取り消されました",
|
||||
"revokeFailed": "シェアの取り消しに失敗しました",
|
||||
"joinCount": "{{count}} 参加する",
|
||||
"joinCount_other": "{{count}} が参加します",
|
||||
"expiresAt": "有効期限 {{date}}",
|
||||
"linkShareBadge": "リンク",
|
||||
"userShareBadge": "ユーザー: {{username}}",
|
||||
"loadSharesFailed": "アクティブな共有の読み込みに失敗しました",
|
||||
"remoteDisplayUnavailable": "現在、リモートデスクトップはご利用いただけません。",
|
||||
"sharedWithMe": "私と共有されたセッション",
|
||||
"customHoursPlaceholder": "アクセス期限までの時間数"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "新しい部屋",
|
||||
"roomName": "部屋名",
|
||||
"persistentRoom": "永続的な部屋",
|
||||
"persistentRoomHint": "永続的な会議室は、会議終了後もリストに表示され、再利用できます。",
|
||||
"noRooms": "まだ会議室がありません。会議室を作成して会議を開始してください。",
|
||||
"openRoom": "開く",
|
||||
"endRoom": "会議終了",
|
||||
"leaveRoom": "離れる",
|
||||
"deleteMember": "削除",
|
||||
"removeMember": "{{name}}を削除",
|
||||
"removeMemberTitle": "このメンバーを削除しますか?",
|
||||
"removeMemberDescription": "{{name}} は直ちにこのルームへのアクセス権を失います。アクティブな SSH ビューアはすべて切断されます。",
|
||||
"invite": "招待する",
|
||||
"inviteTitle": "メンバーを招待する",
|
||||
"members": "メンバー",
|
||||
"membersPanel": "ルームメンバーと制御リクエスト",
|
||||
"membersWithCount": "メンバー({{count}})",
|
||||
"guestsWithCount": "ゲスト ({{count}})",
|
||||
"removeGuests": "ゲストを退出させる",
|
||||
"anonymousGuest": "ゲスト",
|
||||
"online": "オンライン",
|
||||
"offline": "オフライン",
|
||||
"you": "(あなた)",
|
||||
"hostBadge": "ホスト",
|
||||
"presenterBadge": "発表します",
|
||||
"present": "現在",
|
||||
"presentTitle": "ステージに立つ",
|
||||
"presentHost": "ホスト",
|
||||
"searchHosts": "ホストを検索",
|
||||
"noHostsFound": "利用可能なホストが見つかりませんでした。",
|
||||
"presentProtocol": "プロトコル",
|
||||
"stopPresenting": "プレゼンテーションをやめる",
|
||||
"takeOver": "引き継ぐ",
|
||||
"takeOverConfirmTitle": "現在のプレゼンテーションを置き換えますか?",
|
||||
"takeOverDescription": "{{name}} が現在プレゼンテーションを行っています。引き継ぐと、そのステージから全員が切断されます。",
|
||||
"emptyStage": "発表者はいません。ステージに上がってセッションを共有してください。",
|
||||
"stageLoading": "ステージに接続中…",
|
||||
"stageEnded": "プレゼンテーションは終了しました",
|
||||
"roomEnded": "この会議は終了しました。",
|
||||
"presenterLabel": "{{name}} がプレゼンテーションを行っています",
|
||||
"guestLink": "ゲストリンク",
|
||||
"guestLinkOn": "ゲストリンクが有効になっています。リンクを知っている人なら誰でもステージを視聴できます。",
|
||||
"guestLinkOff": "ゲストリンクが無効になっています。",
|
||||
"copyLink": "リンクをコピー",
|
||||
"rotateLink": "リンクを回転させる",
|
||||
"disableLink": "リンクを無効にする",
|
||||
"rotateLinkConfirmTitle": "ゲストリンクを回転させますか?",
|
||||
"rotateLinkDescription": "現在の接続は直ちに停止します。接続中のSSHゲストは切断されます。",
|
||||
"disableLinkConfirmTitle": "ゲストリンクを無効にしますか?",
|
||||
"disableLinkDescription": "匿名SSHビューアは直ちに切断され、現在の接続は機能しなくなります。",
|
||||
"linkCopied": "リンクをコピーしました",
|
||||
"roles": "ロール",
|
||||
"users": "ユーザー",
|
||||
"searchUsers": "ユーザーを検索",
|
||||
"endConfirmTitle": "この会議を終了しますか?",
|
||||
"endDescription": "{{name}} は全員に対して終了し、再開することはできません。",
|
||||
"endPersistentDescription": "{{name}} で現在行われているプレゼンテーションは終了します。この部屋は引き続きご利用いただけます。",
|
||||
"deleteRoom": "ルームを削除",
|
||||
"deleteConfirmTitle": "この部屋を削除しますか?",
|
||||
"deleteDescription": "{{name}} は全員に対して完全に削除され、元に戻すことはできません。",
|
||||
"invitedTo": "あなたは「{{name}}」に招待されました。",
|
||||
"guest": {
|
||||
"title": "ミーティング",
|
||||
"waiting": "プレゼンターをお待ちしています…",
|
||||
"linkInvalid": "このゲストリンクは無効であるか、ミーティングが終了しています"
|
||||
},
|
||||
"reopenFromPanel": "この会議タブの有効期限が切れました。会議パネルから会議室を再度開いてください。",
|
||||
"retry": "再試行",
|
||||
"requestControl": "リクエスト制御",
|
||||
"cancelControlRequest": "キャンセルリクエスト",
|
||||
"controlQueue": "制御要求",
|
||||
"noControlRequests": "保留中のリクエストはありません。",
|
||||
"grantControlTo": "{{name}}に制御権を与える",
|
||||
"dismissControlRequest": "{{name}}の制御要求を却下します",
|
||||
"releaseControl": "リリース制御",
|
||||
"grantControl": "コントロールを与える",
|
||||
"revokeControl": "主導権を取り戻す",
|
||||
"controlRequestedBy": "{{name}} が制御を要求しました",
|
||||
"grant": "付与",
|
||||
"controlBadge": "制御する",
|
||||
"youArePresenting": "あなたは別のタブでプレゼンテーションを行っています。プレゼンテーションを中断して、ステージを譲ってください。",
|
||||
"created": "ルームが作成されました",
|
||||
"invited": "会員の皆様を招待いたします",
|
||||
"roomTab": "ミーティング",
|
||||
"serverChanged": "会議サーバーが変更されました。会議パネルからこの会議を再度開いてください。",
|
||||
"hostNotSynced": "プレゼンテーションを行う前に、このホストを会議サーバーと同期させてください。",
|
||||
"linkRequiresServer": "このデスクトップをTermixサーバーに接続し、そこで会議を作成してWebリンクを共有してください。"
|
||||
},
|
||||
"nav": {
|
||||
"collab": "会議"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "セッション共有を許可する",
|
||||
"description": "ライブターミナル、RDP、VNC、およびTelnetセッションをインスタンス全体で共有できるようにします。無効にすると、ホストごとの共有設定がすべて上書きされます。"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "セッション共有を許可する",
|
||||
"description": "このホスト上のライブセッションをリンクまたは他のユーザーと共有できるようにします。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "セッションを共有し、ルームに参加する",
|
||||
"description": "ライブセッションをリンクまたは他のユーザーと共有したり、コラボレーションルームを作成・参加したりできます。"
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "無期限",
|
||||
"oneHour": "1時間",
|
||||
"oneDay": "24時間",
|
||||
"sevenDays": "7日間",
|
||||
"thirtyDays": "30日間",
|
||||
"custom": "カスタム"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "私と共有しました",
|
||||
"sharedBy": "{{username}} によって共有されました",
|
||||
"join": "参加する",
|
||||
"sharedSessionLabel": "{{hostName}} (共有)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "공유 세션에 연결 중...",
|
||||
"linkInvalid": "이 공유 링크는 유효하지 않거나, 만료되었거나, 취소되었습니다.",
|
||||
"rateLimited": "시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"sessionEnded": "이번 세션이 종료되었습니다.",
|
||||
"readOnlyBadge": "보기 전용",
|
||||
"ownerLabel": "호스트 소유자"
|
||||
},
|
||||
"modalTitle": "공유 세션",
|
||||
"shareButton": "공유",
|
||||
"notReadyToShare": "세션을 공유할 준비가 아직 되지 않았습니다.",
|
||||
"modeTab": {
|
||||
"link": "링크",
|
||||
"user": "사용자"
|
||||
},
|
||||
"linkModeDescription": "이 링크만 있으면 누구나 가입할 수 있으며, 계정은 필요하지 않습니다.",
|
||||
"userModeDescription": "이미 해당 호스트에 접근 권한이 있는 특정 사용자와 공유하세요. 접근 권한이 없는 경우, 먼저 호스트를 공유하거나 링크를 사용하세요. 공유가 완료되면 해당 세션이 사용자의 '연결' 탭에 나타납니다.",
|
||||
"permissionLevel": {
|
||||
"label": "권한 수준",
|
||||
"readOnly": "읽기 전용",
|
||||
"readOnlyDescription": "실시간으로 세션을 시청할 수는 있지만, 입력하거나 상호 작용할 수는 없습니다.",
|
||||
"readWrite": "읽기-쓰기",
|
||||
"readWriteDescription": "소유자와 마찬가지로 세션에 입력하고 상호 작용할 수 있습니다."
|
||||
},
|
||||
"expiryLabel": "링크 만료",
|
||||
"createLinkButton": "링크 생성",
|
||||
"createShareButton": "사용자와 공유",
|
||||
"searchUsersPlaceholder": "사용자 검색...",
|
||||
"noUsersFound": "사용자를 찾을 수 없습니다.",
|
||||
"linkCreated": "공유 링크가 생성되었습니다",
|
||||
"linkNeedsServer": "공유 링크는 Termix 서버의 세션에서만 작동합니다. 링크를 사용하여 공유하려면 연결된 서버를 통해 이 호스트에 연결하십시오.",
|
||||
"linkCopied": "링크가 클립보드에 복사되었습니다",
|
||||
"copyLink": "링크 복사",
|
||||
"shareCreated": "세션이 공유되었습니다. 상대방의 연결 탭에 표시됩니다.",
|
||||
"shareFailed": "공유 폴더 생성에 실패했습니다.",
|
||||
"userLacksHostAccess": "해당 사용자는 아직 이 호스트에 대한 접근 권한이 없습니다. 먼저 호스트를 공유하거나 링크를 사용하세요.",
|
||||
"activeShares": "활성 주식",
|
||||
"noActiveShares": "이번 세션에는 활성 주식이 없습니다.",
|
||||
"revoke": "취소",
|
||||
"revokeConfirmTitle": "이 지분을 취소하시겠습니까?",
|
||||
"revokeConfirmDescription": "이 공유 폴더를 사용하는 사람은 누구든 즉시 접근 권한을 잃게 됩니다.",
|
||||
"revoked": "공유 취소됨",
|
||||
"revokeFailed": "공유 취소에 실패했습니다",
|
||||
"joinCount": "{{count}} 참여",
|
||||
"joinCount_other": "{{count}} 참여",
|
||||
"expiresAt": "만료일: {{date}}",
|
||||
"linkShareBadge": "링크",
|
||||
"userShareBadge": "사용자: {{username}}",
|
||||
"loadSharesFailed": "활성 공유 항목을 로드하는 데 실패했습니다.",
|
||||
"remoteDisplayUnavailable": "현재 원격 데스크톱을 사용할 수 없습니다.",
|
||||
"sharedWithMe": "나와 공유된 세션",
|
||||
"customHoursPlaceholder": "액세스 만료까지 시간"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "새로운 방",
|
||||
"roomName": "객실 이름",
|
||||
"persistentRoom": "지속적인 방",
|
||||
"persistentRoomHint": "회의가 끝난 후에도 지정된 회의실 목록에 남아 재사용할 수 있습니다.",
|
||||
"noRooms": "아직 회의실이 없습니다. 회의를 시작하려면 회의실을 만드세요.",
|
||||
"openRoom": "열기",
|
||||
"endRoom": "회의 종료",
|
||||
"leaveRoom": "떠나다",
|
||||
"deleteMember": "제거",
|
||||
"removeMember": "{{name}} 제거",
|
||||
"removeMemberTitle": "이 회원을 삭제하시겠습니까?",
|
||||
"removeMemberDescription": "{{name}} 사용자는 즉시 이 방에 대한 접근 권한을 잃게 됩니다. 활성화된 SSH 뷰어는 모두 연결이 끊어집니다.",
|
||||
"invite": "초대하다",
|
||||
"inviteTitle": "회원 초대",
|
||||
"members": "회원들",
|
||||
"membersPanel": "회의실 구성원 및 제어 요청",
|
||||
"membersWithCount": "멤버({{count}})",
|
||||
"guestsWithCount": "손님 ({{count}})",
|
||||
"removeGuests": "손님을 제거하세요",
|
||||
"anonymousGuest": "손님",
|
||||
"online": "온라인",
|
||||
"offline": "오프라인",
|
||||
"you": "(너)",
|
||||
"hostBadge": "호스트",
|
||||
"presenterBadge": "소개합니다",
|
||||
"present": "현재의",
|
||||
"presentTitle": "무대에 오르세요",
|
||||
"presentHost": "호스트",
|
||||
"searchHosts": "호스트 검색",
|
||||
"noHostsFound": "사용 가능한 호스트를 찾을 수 없습니다.",
|
||||
"presentProtocol": "프로토콜",
|
||||
"stopPresenting": "프레젠테이션을 중단하세요",
|
||||
"takeOver": "인수하세요",
|
||||
"takeOverConfirmTitle": "현재 프레젠테이션을 교체하시겠습니까?",
|
||||
"takeOverDescription": "{{name}} 님이 현재 발표 중입니다. 발표를 이어받으면 해당 무대에서 모든 사용자의 연결이 끊어집니다.",
|
||||
"emptyStage": "발표자가 없습니다. 무대에 올라 세션을 공유해 보세요.",
|
||||
"stageLoading": "무대와 연결 중...",
|
||||
"stageEnded": "프레젠테이션이 끝났습니다.",
|
||||
"roomEnded": "이 회의는 종료되었습니다.",
|
||||
"presenterLabel": "{{name}} 가 발표합니다",
|
||||
"guestLink": "게스트 링크",
|
||||
"guestLinkOn": "게스트 링크가 활성화되었습니다. 링크만 있으면 누구나 무대를 시청할 수 있습니다.",
|
||||
"guestLinkOff": "게스트 링크가 꺼져 있습니다.",
|
||||
"copyLink": "링크 복사",
|
||||
"rotateLink": "링크를 회전하세요",
|
||||
"disableLink": "링크 비활성화",
|
||||
"rotateLinkConfirmTitle": "게스트 링크를 변경하시겠습니까?",
|
||||
"rotateLinkDescription": "현재 연결은 즉시 작동을 멈춥니다. 연결된 SSH 게스트는 연결이 끊어집니다.",
|
||||
"disableLinkConfirmTitle": "게스트 링크를 비활성화하시겠습니까?",
|
||||
"disableLinkDescription": "익명 SSH 뷰어는 즉시 연결이 끊어지고 현재 링크가 작동을 멈춥니다.",
|
||||
"linkCopied": "링크가 복사되었습니다",
|
||||
"roles": "역할",
|
||||
"users": "사용자",
|
||||
"searchUsers": "사용자 검색",
|
||||
"endConfirmTitle": "이 회의를 종료하시겠습니까?",
|
||||
"endDescription": "{{name}} 는 모든 사용자에게 종료되며 다시 열 수 없습니다.",
|
||||
"endPersistentDescription": "{{name}} 에서 진행 중이던 프레젠테이션이 종료됩니다. 해당 공간은 재사용 가능합니다.",
|
||||
"deleteRoom": "방 삭제",
|
||||
"deleteConfirmTitle": "이 방을 삭제하시겠습니까?",
|
||||
"deleteDescription": "{{name}} 는 모든 사용자에게 영구적으로 삭제되며 되돌릴 수 없습니다.",
|
||||
"invitedTo": "당신은 \"{{name}} \"에 초대되었습니다.",
|
||||
"guest": {
|
||||
"title": "회의",
|
||||
"waiting": "발표자를 기다리는 중...",
|
||||
"linkInvalid": "게스트 링크가 유효하지 않거나 회의가 종료되었습니다."
|
||||
},
|
||||
"reopenFromPanel": "이 회의 탭이 만료되었습니다. 회의 패널에서 회의실을 다시 여세요.",
|
||||
"retry": "재시도",
|
||||
"requestControl": "요청 제어",
|
||||
"cancelControlRequest": "취소 요청",
|
||||
"controlQueue": "제어 요청",
|
||||
"noControlRequests": "보류 중인 요청이 없습니다.",
|
||||
"grantControlTo": "{{name}}에 제어권을 넘겨주세요",
|
||||
"dismissControlRequest": "{{name}}의 제어 요청을 기각합니다.",
|
||||
"releaseControl": "릴리스 컨트롤",
|
||||
"grantControl": "제어권을 넘겨주세요",
|
||||
"revokeControl": "주도권을 되찾으세요",
|
||||
"controlRequestedBy": "{{name}} 제어권을 요청했습니다.",
|
||||
"grant": "승인하다",
|
||||
"controlBadge": "통제하에",
|
||||
"youArePresenting": "다른 탭에서 프레젠테이션을 진행하고 계십니다. 프레젠테이션을 중지하고 다음 사람에게 넘겨주세요.",
|
||||
"created": "방이 생성되었습니다",
|
||||
"invited": "회원 초대",
|
||||
"roomTab": "회의",
|
||||
"serverChanged": "회의 서버가 변경되었습니다. 회의 패널에서 이 회의를 다시 여세요.",
|
||||
"hostNotSynced": "발표하기 전에 이 호스트를 회의 서버와 동기화하세요.",
|
||||
"linkRequiresServer": "이 데스크톱을 Termix 서버에 연결하고, 그곳에서 웹 링크를 공유하는 회의를 생성하세요."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "회의"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "세션 공유 허용",
|
||||
"description": "라이브 터미널, RDP, VNC 및 Telnet 세션을 인스턴스 전체에서 공유할 수 있도록 허용합니다. 이 기능을 비활성화하면 호스트별 공유 설정이 모두 무시됩니다."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "세션 공유 허용",
|
||||
"description": "이 호스트에서 진행되는 라이브 세션을 링크 또는 다른 사용자와 공유하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "세션을 공유하고 회의실에 참여하세요",
|
||||
"description": "링크를 통해 또는 다른 사용자와 실시간 세션을 공유하고, 공동 작업 공간을 만들고 참여하세요."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "안 함",
|
||||
"oneHour": "1시간",
|
||||
"oneDay": "24시간",
|
||||
"sevenDays": "7일",
|
||||
"thirtyDays": "30일",
|
||||
"custom": "사용자 지정"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "나와 공유됨",
|
||||
"sharedBy": "{{username}}님이 공유",
|
||||
"join": "가입하다",
|
||||
"sharedSessionLabel": "{{hostName}} (공유됨)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Verbinding maken met de gedeelde sessie...",
|
||||
"linkInvalid": "Deze deelbare link is ongeldig, verlopen of ingetrokken.",
|
||||
"rateLimited": "Te veel pogingen, probeer het over een tijdje opnieuw.",
|
||||
"sessionEnded": "Deze sessie is afgelopen.",
|
||||
"readOnlyBadge": "Alleen bekijken",
|
||||
"ownerLabel": "Gastheer/gastvrouw"
|
||||
},
|
||||
"modalTitle": "Deel sessie",
|
||||
"shareButton": "Deel",
|
||||
"notReadyToShare": "De sessie is nog niet klaar om te delen.",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Gebruiker"
|
||||
},
|
||||
"linkModeDescription": "Iedereen met deze link kan deelnemen, een account is niet nodig.",
|
||||
"userModeDescription": "Deel de host met een specifieke gebruiker die er al toegang toe heeft. Als die gebruiker nog geen toegang heeft, deel de host dan eerst met hem of haar, of gebruik een link. Zodra de sessie is gedeeld, verschijnt deze in het tabblad 'Verbindingen' van de gebruiker.",
|
||||
"permissionLevel": {
|
||||
"label": "Toegangsniveau",
|
||||
"readOnly": "Alleen-lezen",
|
||||
"readOnlyDescription": "Je kunt de sessie live volgen, maar je kunt niet typen of interageren.",
|
||||
"readWrite": "Lezen en schrijven",
|
||||
"readWriteDescription": "Kan typen en interactie hebben met de sessie, net als de eigenaar."
|
||||
},
|
||||
"expiryLabel": "Link verloopt",
|
||||
"createLinkButton": "Link maken",
|
||||
"createShareButton": "Delen met gebruiker",
|
||||
"searchUsersPlaceholder": "Gebruikers doorzoeken...",
|
||||
"noUsersFound": "Geen gebruikers gevonden",
|
||||
"linkCreated": "Deellink aangemaakt",
|
||||
"linkNeedsServer": "Deellinks werken alleen voor sessies op een Termix-server. Verbind deze host via uw gekoppelde server om deze met een link te delen.",
|
||||
"linkCopied": "Link gekopieerd naar het klembord.",
|
||||
"copyLink": "Kopieer link",
|
||||
"shareCreated": "De sessie is gedeeld. Deze verschijnt in hun tabblad 'Verbindingen'.",
|
||||
"shareFailed": "Aanmaken van de share is mislukt",
|
||||
"userLacksHostAccess": "Die gebruiker heeft nog geen toegang tot deze host. Deel de host eerst met hem of haar, of gebruik een link.",
|
||||
"activeShares": "Actieve aandelen",
|
||||
"noActiveShares": "Geen actieve aandelen voor deze sessie.",
|
||||
"revoke": "Herroepen",
|
||||
"revokeConfirmTitle": "Wilt u dit aandeel intrekken?",
|
||||
"revokeConfirmDescription": "Iedereen die deze gedeelde map gebruikt, verliest onmiddellijk de toegang.",
|
||||
"revoked": "Aandeel ingetrokken",
|
||||
"revokeFailed": "Het intrekken van het aandeel is mislukt.",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} sluit zich aan",
|
||||
"expiresAt": "Verloopt {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "Gebruiker: {{username}}",
|
||||
"loadSharesFailed": "Het laden van actieve aandelen is mislukt.",
|
||||
"remoteDisplayUnavailable": "Externe bureaubladtoegang is momenteel niet beschikbaar.",
|
||||
"sharedWithMe": "Sessies die met mij zijn gedeeld",
|
||||
"customHoursPlaceholder": "Aantal uren tot de toegang verloopt"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Nieuwe kamer",
|
||||
"roomName": "Kamernaam",
|
||||
"persistentRoom": "Permanente kamer",
|
||||
"persistentRoomHint": "Kamers die permanent gereserveerd blijven, blijven na afloop van de vergadering beschikbaar en kunnen opnieuw worden gebruikt.",
|
||||
"noRooms": "Er zijn nog geen vergaderruimtes. Maak er een aan om een vergadering te starten.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "Einde vergadering",
|
||||
"leaveRoom": "Vertrekken",
|
||||
"deleteMember": "Verwijderen",
|
||||
"removeMember": "Verwijder {{name}}",
|
||||
"removeMemberTitle": "Dit lid verwijderen?",
|
||||
"removeMemberDescription": "{{name}} verliest onmiddellijk de toegang tot deze ruimte. Elke actieve SSH-viewer wordt losgekoppeld.",
|
||||
"invite": "Uitnodiging",
|
||||
"inviteTitle": "Nodig leden uit",
|
||||
"members": "Leden",
|
||||
"membersPanel": "Kamerleden en beheerverzoeken",
|
||||
"membersWithCount": "Leden ({{count}})",
|
||||
"guestsWithCount": "Gasten ({{count}})",
|
||||
"removeGuests": "Verwijder gasten",
|
||||
"anonymousGuest": "Gast",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(Jij)",
|
||||
"hostBadge": "Gastheer",
|
||||
"presenterBadge": "Presentatie",
|
||||
"present": "Cadeau",
|
||||
"presentTitle": "Betreed het podium",
|
||||
"presentHost": "Gastheer",
|
||||
"searchHosts": "Zoek hosts",
|
||||
"noHostsFound": "Geen beschikbare hosts gevonden.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop met presenteren",
|
||||
"takeOver": "Neem het over",
|
||||
"takeOverConfirmTitle": "De huidige presentatie vervangen?",
|
||||
"takeOverDescription": "{{name}} presenteert nu. Als je het overneemt, wordt iedereen van dat podium losgekoppeld.",
|
||||
"emptyStage": "Niemand geeft een presentatie. Betreed het podium om een sessie te delen.",
|
||||
"stageLoading": "Verbinding maken met het podium...",
|
||||
"stageEnded": "De presentatie eindigde.",
|
||||
"roomEnded": "Deze vergadering is afgelopen.",
|
||||
"presenterLabel": "{{name}} presenteert",
|
||||
"guestLink": "Gastlink",
|
||||
"guestLinkOn": "De gastlink is ingeschakeld. Iedereen met de link kan het optreden bekijken.",
|
||||
"guestLinkOff": "De gastlink is uitgeschakeld.",
|
||||
"copyLink": "Kopieer link",
|
||||
"rotateLink": "Draai de link",
|
||||
"disableLink": "Link uitschakelen",
|
||||
"rotateLinkConfirmTitle": "De gastlink roteren?",
|
||||
"rotateLinkDescription": "De huidige verbinding wordt onmiddellijk verbroken. Verbonden SSH-gasten worden losgekoppeld.",
|
||||
"disableLinkConfirmTitle": "De gastlink uitschakelen?",
|
||||
"disableLinkDescription": "Anonieme SSH-gebruikers worden onmiddellijk losgekoppeld en de huidige verbinding zal niet meer werken.",
|
||||
"linkCopied": "Link gekopieerd",
|
||||
"roles": "Rollen",
|
||||
"users": "Gebruikers",
|
||||
"searchUsers": "Zoek gebruikers",
|
||||
"endConfirmTitle": "Deze vergadering beëindigen?",
|
||||
"endDescription": "{{name}} zal voor iedereen eindigen en kan niet opnieuw worden geopend.",
|
||||
"endPersistentDescription": "De actieve presentatie in {{name}} wordt gestopt. De ruimte blijft beschikbaar voor hergebruik.",
|
||||
"deleteRoom": "Verwijder kamer",
|
||||
"deleteConfirmTitle": "Deze kamer verwijderen?",
|
||||
"deleteDescription": "{{name}} wordt permanent verwijderd voor iedereen en kan niet ongedaan worden gemaakt.",
|
||||
"invitedTo": "Je werd uitgenodigd voor \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Ontmoeting",
|
||||
"waiting": "Wachten op de presentator...",
|
||||
"linkInvalid": "Deze gastlink is ongeldig of de vergadering is afgelopen."
|
||||
},
|
||||
"reopenFromPanel": "Dit vergadertabblad is verlopen. Heropen de ruimte via het paneel Vergaderingen.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"requestControl": "Verzoekbeheer",
|
||||
"cancelControlRequest": "Verzoek annuleren",
|
||||
"controlQueue": "Controleverzoeken",
|
||||
"noControlRequests": "Geen openstaande aanvragen.",
|
||||
"grantControlTo": "Geef de controle aan {{name}}",
|
||||
"dismissControlRequest": "Negeer het controleverzoek van {{name}}",
|
||||
"releaseControl": "Ontgrendeling",
|
||||
"grantControl": "Geef de controle",
|
||||
"revokeControl": "Neem de controle terug",
|
||||
"controlRequestedBy": "{{name}} vroeg om controle",
|
||||
"grant": "Studiebeurs",
|
||||
"controlBadge": "Onder controle",
|
||||
"youArePresenting": "Je presenteert momenteel in een ander tabblad. Stop met presenteren en geef het woord aan iemand anders.",
|
||||
"created": "Kamer gecreëerd",
|
||||
"invited": "Leden uitgenodigd",
|
||||
"roomTab": "Ontmoeting",
|
||||
"serverChanged": "De vergaderserver is gewijzigd. Heropen deze vergadering via het paneel Vergaderingen.",
|
||||
"hostNotSynced": "Synchroniseer deze host met de vergaderserver voordat u de presentatie geeft.",
|
||||
"linkRequiresServer": "Koppel dit bureaublad aan een Termix-server en maak daar de vergadering aan om een weblink te delen."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Vergaderingen"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Sessiedeling toestaan",
|
||||
"description": "Sta toe dat live terminal-, RDP-, VNC- en Telnet-sessies instantiebreed worden gedeeld. Deze functie overschrijft elke instelling voor delen per host wanneer deze is uitgeschakeld."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Sessiedeling toestaan",
|
||||
"description": "Sta toe dat live sessies op deze host via een link of met andere gebruikers worden gedeeld."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Deel sessies en neem deel aan chatrooms.",
|
||||
"description": "Deel live sessies via een link of met andere gebruikers, en creëer en neem deel aan samenwerkingsruimtes."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nooit",
|
||||
"oneHour": "1 uur",
|
||||
"oneDay": "24 uur",
|
||||
"sevenDays": "7 dagen",
|
||||
"thirtyDays": "30 dagen",
|
||||
"custom": "Aangepast"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Met mij gedeeld",
|
||||
"sharedBy": "Gedeeld door {{username}}",
|
||||
"join": "Meedoen",
|
||||
"sharedSessionLabel": "{{hostName}} (gedeeld)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Bruker"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Fjern",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "tilkoblet",
|
||||
"offline": "frakoblet",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Brukere",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Prøv igjen",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Łączenie z sesją współdzieloną...",
|
||||
"linkInvalid": "Ten link do udostępniania jest nieprawidłowy, wygasł lub został odwołany",
|
||||
"rateLimited": "Zbyt wiele prób, spróbuj ponownie za chwilę",
|
||||
"sessionEnded": "Ta sesja dobiegła końca",
|
||||
"readOnlyBadge": "Tylko do podglądu",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Udostępnij sesję",
|
||||
"shareButton": "Udostępnij",
|
||||
"notReadyToShare": "Sesja nie jest jeszcze gotowa do udostępnienia",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Użytkownik"
|
||||
},
|
||||
"linkModeDescription": "Każdy mający ten link może dołączyć, nie jest wymagane założenie konta.",
|
||||
"userModeDescription": "Udostępnij konkretnemu użytkownikowi, który ma już dostęp do tego hosta. Jeśli jeszcze go nie ma, najpierw udostępnij mu hosta albo skorzystaj z linku. Po udostępnieniu sesja pojawi się na jego karcie Połączenia.",
|
||||
"permissionLevel": {
|
||||
"label": "Poziom uprawnień",
|
||||
"readOnly": "Tylko do odczytu",
|
||||
"readOnlyDescription": "Może oglądać sesję na żywo, ale nie może pisać ani nic w niej zmieniać.",
|
||||
"readWrite": "Odczyt-zapis",
|
||||
"readWriteDescription": "Może pisać i uczestniczyć w sesji tak samo jak właściciel."
|
||||
},
|
||||
"expiryLabel": "Wygaśnięcie linku",
|
||||
"createLinkButton": "Utwórz link",
|
||||
"createShareButton": "Udostępnij użytkownikowi",
|
||||
"searchUsersPlaceholder": "Wyszukaj użytkowników...",
|
||||
"noUsersFound": "Nie znaleziono użytkowników",
|
||||
"linkCreated": "Utworzono link do udostępniania",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link skopiowany do schowka",
|
||||
"copyLink": "Kopiuj link",
|
||||
"shareCreated": "Udostępniono sesję. Pojawi się na jego karcie Połączenia.",
|
||||
"shareFailed": "Nie udało się utworzyć udostępnienia",
|
||||
"userLacksHostAccess": "Ten użytkownik nie ma jeszcze dostępu do tego hosta. Udostępnij mu najpierw hosta lub skorzystaj z linku.",
|
||||
"activeShares": "Aktywne udostępnienia",
|
||||
"noActiveShares": "Brak aktywnych udostępnień tej sesji",
|
||||
"revoke": "Unieważnij",
|
||||
"revokeConfirmTitle": "Cofnąć to udostępnienie?",
|
||||
"revokeConfirmDescription": "Każdy, kto korzysta z tego udostępnienia, natychmiast utraci dostęp.",
|
||||
"revoked": "Unieważniono udostępnienie",
|
||||
"revokeFailed": "Nie udało się unieważnić udostępnienia",
|
||||
"joinCount": "{{count}} dołączenie",
|
||||
"joinCount_other": "{{count}} dołączeń",
|
||||
"expiresAt": "Wygasa {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "Użytkownik: {{username}}",
|
||||
"loadSharesFailed": "Nie udało się załadować aktywnych udostępnień",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Godziny do wygaśnięcia dostępu"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Otwórz",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Usuń",
|
||||
"removeMember": "Usuń {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Wyszukaj hosty",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protokół",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Kopiuj link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link skopiowany",
|
||||
"roles": "Role",
|
||||
"users": "Użytkownicy",
|
||||
"searchUsers": "Wyszukaj użytkowników",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Ponów",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Zezwól na udostępnianie sesji",
|
||||
"description": "Zezwalaj na udostępnianie sesji terminala na żywo, RDP, VNC i Telnet w obrębie instancji. Po wyłączeniu zastępuje wszystkie przełączniki udostępniania dla poszczególnych hostów."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Zezwól na udostępnianie sesji",
|
||||
"description": "Zezwól na udostępnianie sesji na żywo na tym hoście przez link lub innym użytkownikom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nigdy",
|
||||
"oneHour": "1 godzina",
|
||||
"oneDay": "24 godziny",
|
||||
"sevenDays": "7 dni",
|
||||
"thirtyDays": "30 dni",
|
||||
"custom": "Niestandardowy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Udostępniono mi",
|
||||
"sharedBy": "Udostępnione przez {{username}}",
|
||||
"join": "Dołącz",
|
||||
"sharedSessionLabel": "{{hostName}} (udostępnione)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Usuário"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remover",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Partilhar",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Utilizador"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Nível de permissão",
|
||||
"readOnly": "Apenas leitura",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revogar",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expira a {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Horas até à expiração do acesso"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Abrir",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remover",
|
||||
"removeMember": "Remover {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Servidor",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Servidor",
|
||||
"searchHosts": "Pesquisar hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocolo",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Ligação copiada",
|
||||
"roles": "Funções",
|
||||
"users": "Utilizadores",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Repetir",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nunca",
|
||||
"oneHour": "1 hora",
|
||||
"oneDay": "24 horas",
|
||||
"sevenDays": "7 dias",
|
||||
"thirtyDays": "30 dias",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Conectare la sesiunea partajată...",
|
||||
"linkInvalid": "Acest link de partajare este invalid, a expirat sau a fost revocat.",
|
||||
"rateLimited": "Prea multe încercări, vă rugăm să încercați din nou în scurt timp",
|
||||
"sessionEnded": "Această sesiune s-a încheiat",
|
||||
"readOnlyBadge": "Numai vizualizare",
|
||||
"ownerLabel": "Proprietarul gazdei"
|
||||
},
|
||||
"modalTitle": "Partajați sesiunea",
|
||||
"shareButton": "Distribuie",
|
||||
"notReadyToShare": "Sesiunea nu este încă gata de partajare",
|
||||
"modeTab": {
|
||||
"link": "Legătură",
|
||||
"user": "Utilizator"
|
||||
},
|
||||
"linkModeDescription": "Oricine are acest link se poate alătura, nu este necesar un cont.",
|
||||
"userModeDescription": "Partajați cu un anumit utilizator care are deja acces la această gazdă. Dacă nu are încă acces, partajați mai întâi gazda cu acesta sau utilizați un link în schimb. După partajare, sesiunea apare în fila Conexiuni.",
|
||||
"permissionLevel": {
|
||||
"label": "Nivel de permisiune",
|
||||
"readOnly": "Doar citire",
|
||||
"readOnlyDescription": "Pot urmări sesiunea în direct, dar nu pot tasta sau interacționa.",
|
||||
"readWrite": "Citire-scriere",
|
||||
"readWriteDescription": "Poate tasta și interacționa cu sesiunea la fel ca proprietarul."
|
||||
},
|
||||
"expiryLabel": "Expirarea linkului",
|
||||
"createLinkButton": "Creați un link",
|
||||
"createShareButton": "Partajează cu utilizatorul",
|
||||
"searchUsersPlaceholder": "Căutați utilizatori...",
|
||||
"noUsersFound": "Nu au fost găsiți utilizatori",
|
||||
"linkCreated": "Link de partajare creat",
|
||||
"linkNeedsServer": "Linkurile de partajare funcționează doar pentru sesiunile de pe un server Termix. Conectați această gazdă prin serverul conectat pentru a o partaja printr-un link.",
|
||||
"linkCopied": "Linkul a fost copiat în clipboard",
|
||||
"copyLink": "Copiază linkul",
|
||||
"shareCreated": "Sesiune partajată. Va apărea în fila Conexiuni.",
|
||||
"shareFailed": "Nu s-a putut crea partajarea",
|
||||
"userLacksHostAccess": "Utilizatorul respectiv nu are încă acces la această gazdă. Partajați mai întâi gazda cu acesta sau folosiți un link în schimb.",
|
||||
"activeShares": "Acțiuni active",
|
||||
"noActiveShares": "Nicio partajare activă pentru această sesiune",
|
||||
"revoke": "Revoca",
|
||||
"revokeConfirmTitle": "Revocați această partajare?",
|
||||
"revokeConfirmDescription": "Oricine folosește această partajare va pierde accesul imediat.",
|
||||
"revoked": "Acțiune revocată",
|
||||
"revokeFailed": "Nu s-a putut revoca partajarea",
|
||||
"joinCount": "{{count}} alătură-te",
|
||||
"joinCount_other": "{{count}} se alătură",
|
||||
"expiresAt": "Expiră la {{date}}",
|
||||
"linkShareBadge": "Legătură",
|
||||
"userShareBadge": "Utilizator: {{username}}",
|
||||
"loadSharesFailed": "Nu s-au putut încărca partajările active",
|
||||
"remoteDisplayUnavailable": "Desktopul la distanță nu este disponibil momentan.",
|
||||
"sharedWithMe": "Sesiuni partajate cu mine",
|
||||
"customHoursPlaceholder": "Ore până la expirarea accesului"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "Cameră nouă",
|
||||
"roomName": "Numele camerei",
|
||||
"persistentRoom": "Cameră persistentă",
|
||||
"persistentRoomHint": "Sălile persistente rămân listate după încheierea întâlnirii și pot fi reutilizate.",
|
||||
"noRooms": "Încă nu există săli. Creează una pentru a începe o întâlnire.",
|
||||
"openRoom": "Deschide",
|
||||
"endRoom": "Încheiați întâlnirea",
|
||||
"leaveRoom": "Pleacă",
|
||||
"deleteMember": "Elimina",
|
||||
"removeMember": "Eliminați {{name}}",
|
||||
"removeMemberTitle": "Eliminați acest membru?",
|
||||
"removeMemberDescription": "{{name}} va pierde imediat accesul la această cameră. Orice vizualizator SSH activ va fi deconectat.",
|
||||
"invite": "Invita",
|
||||
"inviteTitle": "Invită membri",
|
||||
"members": "Membri",
|
||||
"membersPanel": "Membrii camerei și solicitările de control",
|
||||
"membersWithCount": "Membri ({{count}})",
|
||||
"guestsWithCount": "Oaspeți ({{count}})",
|
||||
"removeGuests": "Eliminați invitații",
|
||||
"anonymousGuest": "Oaspete",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(tu)",
|
||||
"hostBadge": "Gazdă",
|
||||
"presenterBadge": "Prezentare",
|
||||
"present": "Prezent",
|
||||
"presentTitle": "Urcă pe scenă",
|
||||
"presentHost": "Gazdă",
|
||||
"searchHosts": "Căutați gazde",
|
||||
"noHostsFound": "Nu au fost găsite gazde disponibile.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Opriți prezentarea",
|
||||
"takeOver": "Prelua",
|
||||
"takeOverConfirmTitle": "Înlocuiți prezentarea curentă?",
|
||||
"takeOverDescription": "{{name}} prezintă acum. Preluarea controlului îi va deconecta pe toți de la acea etapă.",
|
||||
"emptyStage": "Nimeni nu prezintă. Urcă pe scenă pentru a împărtăși o sesiune.",
|
||||
"stageLoading": "Conectarea la scenă...",
|
||||
"stageEnded": "Prezentarea s-a încheiat",
|
||||
"roomEnded": "Această întâlnire s-a încheiat",
|
||||
"presenterLabel": "{{name}} prezintă",
|
||||
"guestLink": "Link pentru invitați",
|
||||
"guestLinkOn": "Linkul pentru invitați este activat. Oricine are linkul poate viziona scena.",
|
||||
"guestLinkOff": "Linkul pentru invitați este dezactivat.",
|
||||
"copyLink": "Copiază linkul",
|
||||
"rotateLink": "Rotiți linkul",
|
||||
"disableLink": "Dezactivați linkul",
|
||||
"rotateLinkConfirmTitle": "Rotește linkul pentru oaspeți?",
|
||||
"rotateLinkDescription": "Legătura curentă va înceta să funcționeze imediat. Oaspeții SSH conectați vor fi deconectați.",
|
||||
"disableLinkConfirmTitle": "Dezactivați linkul pentru invitați?",
|
||||
"disableLinkDescription": "Vizualizatorii SSH anonimi vor fi deconectați imediat, iar legătura curentă va înceta să funcționeze.",
|
||||
"linkCopied": "Linkul a fost copiat",
|
||||
"roles": "Roluri",
|
||||
"users": "Utilizatori",
|
||||
"searchUsers": "Căutați utilizatori",
|
||||
"endConfirmTitle": "Încheiați această întâlnire?",
|
||||
"endDescription": "{{name}} se va încheia pentru toată lumea și nu va putea fi redeschis.",
|
||||
"endPersistentDescription": "Prezentarea activă din {{name}} se va opri. Sala rămâne disponibilă pentru reutilizare.",
|
||||
"deleteRoom": "Ștergeți camera",
|
||||
"deleteConfirmTitle": "Ștergeți această cameră?",
|
||||
"deleteDescription": "{{name}} va fi șters definitiv pentru toată lumea și nu poate fi anulat.",
|
||||
"invitedTo": "Ai fost invitat la „{{name}}”",
|
||||
"guest": {
|
||||
"title": "Întâlnire",
|
||||
"waiting": "În așteptarea prezentatorului...",
|
||||
"linkInvalid": "Acest link de invitat este invalid sau întâlnirea s-a încheiat."
|
||||
},
|
||||
"reopenFromPanel": "Această filă de întâlnire a expirat. Redeschideți sala din panoul Întâlniri.",
|
||||
"retry": "Reîncercați",
|
||||
"requestControl": "Cerere de control",
|
||||
"cancelControlRequest": "Anulează cererea",
|
||||
"controlQueue": "Cereri de control",
|
||||
"noControlRequests": "Nicio solicitare în așteptare.",
|
||||
"grantControlTo": "Acordă controlul către {{name}}",
|
||||
"dismissControlRequest": "Închideți solicitarea de control a utilizatorului {{name}}",
|
||||
"releaseControl": "Controlul eliberării",
|
||||
"grantControl": "Oferă controlul",
|
||||
"revokeControl": "Preia controlul",
|
||||
"controlRequestedBy": "{{name}} a cerut controlul",
|
||||
"grant": "Acorda",
|
||||
"controlBadge": "În control",
|
||||
"youArePresenting": "Prezentați într-o altă filă. Oprește prezentarea pentru a preda scena.",
|
||||
"created": "Cameră creată",
|
||||
"invited": "Membrii invitați",
|
||||
"roomTab": "Întâlnire",
|
||||
"serverChanged": "Serverul de întâlnire s-a modificat. Redeschideți această întâlnire din panoul Întâlniri.",
|
||||
"hostNotSynced": "Sincronizează această gazdă cu serverul de întâlnire înainte de prezentare.",
|
||||
"linkRequiresServer": "Conectează acest desktop la un server Termix și creează întâlnirea acolo pentru a partaja un link web."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Întâlniri"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Permiteți partajarea sesiunii",
|
||||
"description": "Permite partajarea sesiunilor live de terminal, RDP, VNC și Telnet la nivel de instanță. Suprascrie fiecare comutare de partajare per gazdă atunci când este dezactivată."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Permiteți partajarea sesiunii",
|
||||
"description": "Permiteți partajarea sesiunilor live de pe această gazdă prin link sau cu alți utilizatori"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Partajați sesiuni și alăturați-vă sălilor",
|
||||
"description": "Partajați sesiuni live prin link sau cu alți utilizatori și creați și alăturați-vă sălilor de colaborare."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Nu",
|
||||
"oneHour": "1 oră",
|
||||
"oneDay": "24 de ore",
|
||||
"sevenDays": "7 zile",
|
||||
"thirtyDays": "30 de zile",
|
||||
"custom": "Personalizat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Distribuit cu mine",
|
||||
"sharedBy": "Distribuit de {{username}}",
|
||||
"join": "Alătură-te",
|
||||
"sharedSessionLabel": "{{hostName}} (partajat)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Поделиться",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Уровень доступа",
|
||||
"readOnly": "Только чтение",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Отозвать",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Истекает {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Часов до окончания срока доступа"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Открыть",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Удалить",
|
||||
"removeMember": "Удалить {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "онлайн",
|
||||
"offline": "офлайн",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Хост",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Хост",
|
||||
"searchHosts": "Поиск хостов",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Протокол",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Ссылка скопирована",
|
||||
"roles": "Роли",
|
||||
"users": "Пользователи",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Повторить",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Никогда",
|
||||
"oneHour": "1 час",
|
||||
"oneDay": "24 часа",
|
||||
"sevenDays": "7 дней",
|
||||
"thirtyDays": "30 дней",
|
||||
"custom": "Настроить"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Paylaş",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Kullanıcı"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "İzin düzeyi",
|
||||
"readOnly": "Salt okunur",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "İptal Et",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Son kullanma: {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Erişim süresi (saat)"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Aç",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Kaldır",
|
||||
"removeMember": "{{name}} kaldır",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "çevrimiçi",
|
||||
"offline": "çevrimdışı",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Ana Bilgisayar",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Ana Bilgisayar",
|
||||
"searchHosts": "Host ara",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protokol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Bağlantı kopyalandı",
|
||||
"roles": "Roller",
|
||||
"users": "Kullanıcılar",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Yeniden dene",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Süresiz",
|
||||
"oneHour": "1 saat",
|
||||
"oneDay": "24 saat",
|
||||
"sevenDays": "7 gün",
|
||||
"thirtyDays": "30 gün",
|
||||
"custom": "Özel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Hours until access expires"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"removeMember": "Remove {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"searchHosts": "Search hosts",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Retry",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Never",
|
||||
"oneHour": "1 hour",
|
||||
"oneDay": "24 hours",
|
||||
"sevenDays": "7 days",
|
||||
"thirtyDays": "30 days",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Chia sẻ",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "Người dùng"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Cấp độ quyền",
|
||||
"readOnly": "Chỉ đọc",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Thu hồi",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Hết hạn {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "Số giờ cho đến khi hết hạn truy cập"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Mở",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Xóa",
|
||||
"removeMember": "Xóa {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "trực tuyến",
|
||||
"offline": "ngoại tuyến",
|
||||
"you": "(you)",
|
||||
"hostBadge": "Máy chủ",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Máy chủ",
|
||||
"searchHosts": "Tìm kiếm máy chủ",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "Giao thức",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Đã sao chép liên kết",
|
||||
"roles": "Vai trò",
|
||||
"users": "Người dùng",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "Thử lại",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "Không bao giờ",
|
||||
"oneHour": "1 giờ",
|
||||
"oneDay": "24 giờ",
|
||||
"sevenDays": "7 ngày",
|
||||
"thirtyDays": "30 ngày",
|
||||
"custom": "Tùy chỉnh"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "此共享链接无效、已过期或已被撤销。",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "此会话已结束",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "共享会话",
|
||||
"shareButton": "共享",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "链接",
|
||||
"user": "用户"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "将主机共享给已拥有此主机访问权限的特定用户。如果他们还没有访问权限,请先将主机共享给他们,或者使用链接代替。共享后,会话将显示在他们的“连接”标签页中。",
|
||||
"permissionLevel": {
|
||||
"label": "权限级别",
|
||||
"readOnly": "只读",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "与用户共享",
|
||||
"searchUsersPlaceholder": "搜索用户......",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "已创建共享链接",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "会话已共享。它将出现在对方的“连接”标签页中。",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "有效共享",
|
||||
"noActiveShares": "此会话暂无活跃共享",
|
||||
"revoke": "吊销",
|
||||
"revokeConfirmTitle": "撤销此共享?",
|
||||
"revokeConfirmDescription": "任何使用此共享的用户都将立即失去访问权限。",
|
||||
"revoked": "共享已撤销",
|
||||
"revokeFailed": "撤销共享失败",
|
||||
"joinCount": "{{count}} 人加入",
|
||||
"joinCount_other": "{{count}} 人加入",
|
||||
"expiresAt": "过期于 {{date}}",
|
||||
"linkShareBadge": "链接",
|
||||
"userShareBadge": "用户:{{username}}",
|
||||
"loadSharesFailed": "无法加载共享",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "过期前的小时数"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "打开",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "删除",
|
||||
"removeMember": "移除{{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"you": "(you)",
|
||||
"hostBadge": "主机",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "主机",
|
||||
"searchHosts": "搜索主机",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "协议",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "角色",
|
||||
"users": "用户",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "重试",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "允许通过链接或其他方式共享此主机上的实时会话。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "永不过期",
|
||||
"oneHour": "1 小时",
|
||||
"oneDay": "24 小时",
|
||||
"sevenDays": "7 天",
|
||||
"thirtyDays": "30 天",
|
||||
"custom": "自定义"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "与我共享",
|
||||
"sharedBy": "由 {{username}} 共享",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}}(共享)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only",
|
||||
"ownerLabel": "Host owner"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "分享",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "使用者"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "權限層級",
|
||||
"readOnly": "唯讀",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "撤銷",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "到期日 {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares",
|
||||
"remoteDisplayUnavailable": "Remote desktop is not available right now.",
|
||||
"sharedWithMe": "Sessions shared with me",
|
||||
"customHoursPlaceholder": "存取到期前的小時數"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "開啟",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "移除",
|
||||
"removeMember": "移除 {{name}}",
|
||||
"removeMemberTitle": "Remove this member?",
|
||||
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"membersPanel": "Room members and control requests",
|
||||
"membersWithCount": "Members ({{count}})",
|
||||
"guestsWithCount": "Guests ({{count}})",
|
||||
"removeGuests": "Remove guests",
|
||||
"anonymousGuest": "Guest",
|
||||
"online": "在線",
|
||||
"offline": "離線",
|
||||
"you": "(you)",
|
||||
"hostBadge": "主機",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "主機",
|
||||
"searchHosts": "搜尋主機",
|
||||
"noHostsFound": "No available hosts found.",
|
||||
"presentProtocol": "通訊協定",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"takeOverConfirmTitle": "Replace the current presentation?",
|
||||
"takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"rotateLink": "Rotate link",
|
||||
"disableLink": "Disable link",
|
||||
"rotateLinkConfirmTitle": "Rotate the guest link?",
|
||||
"rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.",
|
||||
"disableLinkConfirmTitle": "Disable the guest link?",
|
||||
"disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "角色",
|
||||
"users": "使用者",
|
||||
"searchUsers": "Search users",
|
||||
"endConfirmTitle": "End this meeting?",
|
||||
"endDescription": "{{name}} will end for everyone and cannot be reopened.",
|
||||
"endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleteConfirmTitle": "Delete this room?",
|
||||
"deleteDescription": "{{name}} will be permanently deleted for everyone and cannot be undone.",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"retry": "重試",
|
||||
"requestControl": "Request control",
|
||||
"cancelControlRequest": "Cancel request",
|
||||
"controlQueue": "Control requests",
|
||||
"noControlRequests": "No pending requests.",
|
||||
"grantControlTo": "Give control to {{name}}",
|
||||
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
},
|
||||
"settings": {
|
||||
"admin": {
|
||||
"globallyEnabled": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled."
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"allowSessionSharing": {
|
||||
"label": "Allow Session Sharing",
|
||||
"description": "Let live sessions on this host be shared via link or with other users"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Share sessions and join rooms",
|
||||
"description": "Share live sessions by link or with other users, and create and join collaboration rooms."
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"sharing": {
|
||||
"expiry": {
|
||||
"never": "永不",
|
||||
"oneHour": "1 小時",
|
||||
"oneDay": "24 小時",
|
||||
"sevenDays": "7 天",
|
||||
"thirtyDays": "30 天",
|
||||
"custom": "自訂"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"name": "Session Sharing",
|
||||
"version": "1.0.0",
|
||||
"description": "Share a live terminal or remote desktop session by link or with another user, and present sessions in collaboration rooms.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Productivity",
|
||||
"icon": "Presentation",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"users:impersonate",
|
||||
"db:core-refs"
|
||||
],
|
||||
"dependencies": {
|
||||
"ssh-terminal": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"remote-desktop": "^1.0.0"
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"service": "sessions.sharing",
|
||||
"version": "1.0.0",
|
||||
"permission": "session-sharing.use"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
{
|
||||
"service": "sessions.live",
|
||||
"versionRange": "^1.0.0"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"guest": true,
|
||||
"guestViews": ["shared", "collab-guest"],
|
||||
"tabs": [
|
||||
{
|
||||
"id": "collab",
|
||||
"titleKey": "nav.collab",
|
||||
"icon": "Presentation",
|
||||
"openFrom": ["rail"]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "session-sharing.share",
|
||||
"titleKey": "sessionSharing.shareButton",
|
||||
"handler": "share",
|
||||
"permission": "session-sharing.use"
|
||||
},
|
||||
{
|
||||
"id": "sessions.sharedWithMe",
|
||||
"titleKey": "sessionSharing.sharedWithMe",
|
||||
"handler": "sharedWithMe",
|
||||
"permission": "session-sharing.use"
|
||||
}
|
||||
],
|
||||
"actionSlots": [
|
||||
{
|
||||
"id": "session.remoteDisplay",
|
||||
"accepts": ["component"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "globallyEnabled",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.admin.globallyEnabled.label",
|
||||
"descriptionKey": "settings.admin.globallyEnabled.description",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"host": {
|
||||
"editorGroup": "ssh",
|
||||
"editorOrder": 80,
|
||||
"fields": [
|
||||
{
|
||||
"key": "allowSessionSharing",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.allowSessionSharing.label",
|
||||
"descriptionKey": "settings.host.allowSessionSharing.description",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"uses": ["session.remoteDisplay.token", "terminal.open", "terminal.overlay"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
-- session-sharing 0001: adopt_session_sharing
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
|
||||
-- (nullable or set-null user and host links, and links between these tables).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `session_shares` (
|
||||
`id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`owner_user_id` varchar(255) NOT NULL,
|
||||
`protocol` text NOT NULL,
|
||||
`session_id` varchar(255) NOT NULL,
|
||||
`tab_instance_id` text,
|
||||
`share_type` text NOT NULL,
|
||||
`target_user_id` varchar(255),
|
||||
`link_token` varchar(255) UNIQUE,
|
||||
`permission_level` text NOT NULL DEFAULT ('read-only'),
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`expires_at` text NOT NULL,
|
||||
`revoked_at` text,
|
||||
`last_joined_at` text,
|
||||
`join_count` int NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`target_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `session_shares` TO `p_session_sharing_shares`;
|
||||
CREATE TABLE IF NOT EXISTS `session_share_participants` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`share_id` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255),
|
||||
`guest_label` text,
|
||||
`joined_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`left_at` text,
|
||||
FOREIGN KEY (`share_id`) REFERENCES `p_session_sharing_shares` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `session_share_participants` TO `p_session_sharing_share_participants`;
|
||||
CREATE TABLE IF NOT EXISTS `collab_rooms` (
|
||||
`id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`owner_user_id` varchar(255) NOT NULL,
|
||||
`persistent` boolean NOT NULL DEFAULT false,
|
||||
`presenter_user_id` varchar(255),
|
||||
`stage_protocol` text,
|
||||
`stage_host_id` int,
|
||||
`stage_share_id` varchar(255),
|
||||
`guest_link_token` varchar(255),
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`ended_at` text,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`presenter_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL,
|
||||
FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data` (`id`) ON DELETE SET NULL,
|
||||
FOREIGN KEY (`stage_share_id`) REFERENCES `p_session_sharing_shares` (`id`) ON DELETE SET NULL
|
||||
);
|
||||
RENAME TABLE `collab_rooms` TO `p_session_sharing_rooms`;
|
||||
CREATE TABLE IF NOT EXISTS `collab_room_members` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`room_id` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`room_role` text NOT NULL DEFAULT ('member'),
|
||||
`added_by` varchar(255),
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `p_session_sharing_rooms` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
);
|
||||
RENAME TABLE `collab_room_members` TO `p_session_sharing_room_members`;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- session-sharing 0001: adopt_session_sharing
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
|
||||
-- (nullable or set-null user and host links, and links between these tables).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "session_shares" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"owner_user_id" varchar(255) NOT NULL,
|
||||
"protocol" text NOT NULL,
|
||||
"session_id" varchar(255) NOT NULL,
|
||||
"tab_instance_id" text,
|
||||
"share_type" text NOT NULL,
|
||||
"target_user_id" varchar(255),
|
||||
"link_token" varchar(255) UNIQUE,
|
||||
"permission_level" text NOT NULL DEFAULT 'read-only',
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" text NOT NULL,
|
||||
"revoked_at" text,
|
||||
"last_joined_at" text,
|
||||
"join_count" integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("target_user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_session_shares_session_id" ON "session_shares" ("session_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_session_shares_host_id" ON "session_shares" ("host_id");
|
||||
ALTER TABLE "session_shares" RENAME TO "p_session_sharing_shares";
|
||||
CREATE TABLE IF NOT EXISTS "session_share_participants" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"share_id" varchar(255) NOT NULL,
|
||||
"user_id" varchar(255),
|
||||
"guest_label" text,
|
||||
"joined_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"left_at" text,
|
||||
FOREIGN KEY ("share_id") REFERENCES "p_session_sharing_shares" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "session_share_participants" RENAME TO "p_session_sharing_share_participants";
|
||||
CREATE TABLE IF NOT EXISTS "collab_rooms" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"owner_user_id" varchar(255) NOT NULL,
|
||||
"persistent" boolean NOT NULL DEFAULT false,
|
||||
"presenter_user_id" varchar(255),
|
||||
"stage_protocol" text,
|
||||
"stage_host_id" integer,
|
||||
"stage_share_id" varchar(255),
|
||||
"guest_link_token" varchar(255),
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ended_at" text,
|
||||
FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("presenter_user_id") REFERENCES "users" ("id") ON DELETE SET NULL,
|
||||
FOREIGN KEY ("stage_host_id") REFERENCES "ssh_data" ("id") ON DELETE SET NULL,
|
||||
FOREIGN KEY ("stage_share_id") REFERENCES "p_session_sharing_shares" ("id") ON DELETE SET NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_collab_rooms_guest_token" ON "collab_rooms" ("guest_link_token");
|
||||
CREATE INDEX IF NOT EXISTS "idx_collab_rooms_owner" ON "collab_rooms" ("owner_user_id");
|
||||
ALTER TABLE "collab_rooms" RENAME TO "p_session_sharing_rooms";
|
||||
CREATE TABLE IF NOT EXISTS "collab_room_members" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"room_id" varchar(255) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"room_role" text NOT NULL DEFAULT 'member',
|
||||
"added_by" varchar(255),
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("room_id") REFERENCES "p_session_sharing_rooms" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("added_by") REFERENCES "users" ("id") ON DELETE SET NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_collab_room_members_room_user" ON "collab_room_members" ("room_id", "user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_collab_room_members_user" ON "collab_room_members" ("user_id");
|
||||
ALTER TABLE "collab_room_members" RENAME TO "p_session_sharing_room_members";
|
||||
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"shares": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"ownerUserId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"protocol": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"tabInstanceId": {
|
||||
"type": "text"
|
||||
},
|
||||
"shareType": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"targetUserId": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"linkToken": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"unique": true
|
||||
},
|
||||
"permissionLevel": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "read-only"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"revokedAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"lastJoinedAt": {
|
||||
"type": "text"
|
||||
},
|
||||
"joinCount": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 0
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_session_shares_session_id",
|
||||
"columns": ["sessionId"]
|
||||
},
|
||||
{
|
||||
"name": "idx_session_shares_host_id",
|
||||
"columns": ["hostId"]
|
||||
}
|
||||
],
|
||||
"adopts": "session_shares"
|
||||
},
|
||||
"share_participants": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"shareId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"guestLabel": {
|
||||
"type": "text"
|
||||
},
|
||||
"joinedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"leftAt": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"indexes": [],
|
||||
"adopts": "session_share_participants"
|
||||
},
|
||||
"rooms": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"ownerUserId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"persistent": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"presenterUserId": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"stageProtocol": {
|
||||
"type": "text"
|
||||
},
|
||||
"stageHostId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"stageShareId": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"guestLinkToken": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"endedAt": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_collab_rooms_guest_token",
|
||||
"columns": ["guestLinkToken"],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "idx_collab_rooms_owner",
|
||||
"columns": ["ownerUserId"]
|
||||
}
|
||||
],
|
||||
"adopts": "collab_rooms"
|
||||
},
|
||||
"room_members": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"roomId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"roomRole": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "member"
|
||||
},
|
||||
"addedBy": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_collab_room_members_room_user",
|
||||
"columns": ["roomId", "userId"],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "idx_collab_room_members_user",
|
||||
"columns": ["userId"]
|
||||
}
|
||||
],
|
||||
"adopts": "collab_room_members"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
-- session-sharing 0001: adopt_session_sharing
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
|
||||
-- (nullable or set-null user and host links, and links between these tables).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "session_shares" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"owner_user_id" text NOT NULL,
|
||||
"protocol" text NOT NULL,
|
||||
"session_id" text NOT NULL,
|
||||
"tab_instance_id" text,
|
||||
"share_type" text NOT NULL,
|
||||
"target_user_id" text,
|
||||
"link_token" text UNIQUE,
|
||||
"permission_level" text NOT NULL DEFAULT 'read-only',
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" text NOT NULL,
|
||||
"revoked_at" text,
|
||||
"last_joined_at" text,
|
||||
"join_count" integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("target_user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_session_shares_session_id" ON "session_shares" ("session_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_session_shares_host_id" ON "session_shares" ("host_id");
|
||||
ALTER TABLE "session_shares" RENAME TO "p_session_sharing_shares";
|
||||
CREATE TABLE IF NOT EXISTS "session_share_participants" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"share_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"guest_label" text,
|
||||
"joined_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"left_at" text,
|
||||
FOREIGN KEY ("share_id") REFERENCES "p_session_sharing_shares" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "session_share_participants" RENAME TO "p_session_sharing_share_participants";
|
||||
CREATE TABLE IF NOT EXISTS "collab_rooms" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"owner_user_id" text NOT NULL,
|
||||
"persistent" integer NOT NULL DEFAULT 0,
|
||||
"presenter_user_id" text,
|
||||
"stage_protocol" text,
|
||||
"stage_host_id" integer,
|
||||
"stage_share_id" text,
|
||||
"guest_link_token" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ended_at" text,
|
||||
FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("presenter_user_id") REFERENCES "users" ("id") ON DELETE SET NULL,
|
||||
FOREIGN KEY ("stage_host_id") REFERENCES "ssh_data" ("id") ON DELETE SET NULL,
|
||||
FOREIGN KEY ("stage_share_id") REFERENCES "p_session_sharing_shares" ("id") ON DELETE SET NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_collab_rooms_guest_token" ON "collab_rooms" ("guest_link_token");
|
||||
CREATE INDEX IF NOT EXISTS "idx_collab_rooms_owner" ON "collab_rooms" ("owner_user_id");
|
||||
ALTER TABLE "collab_rooms" RENAME TO "p_session_sharing_rooms";
|
||||
CREATE TABLE IF NOT EXISTS "collab_room_members" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"room_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"room_role" text NOT NULL DEFAULT 'member',
|
||||
"added_by" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("room_id") REFERENCES "p_session_sharing_rooms" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("added_by") REFERENCES "users" ("id") ON DELETE SET NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_collab_room_members_room_user" ON "collab_room_members" ("room_id", "user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_collab_room_members_user" ON "collab_room_members" ("user_id");
|
||||
ALTER TABLE "collab_room_members" RENAME TO "p_session_sharing_room_members";
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@termix-plugin/session-sharing",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"redis": "^6.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"xterm-android-ime"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"react-xtermjs": {
|
||||
"@xterm/xterm": "$@xterm/xterm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { LiveSessions } from "./live.js";
|
||||
import type { RateLimiter } from "./rate-limit.js";
|
||||
import type {
|
||||
Directory,
|
||||
RoomRepository,
|
||||
ShareRepository,
|
||||
} from "./repositories.js";
|
||||
import type { CollabRoomHub } from "./room-hub.js";
|
||||
import type { CollabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
/** Everything the routes and services share, built once in activate. */
|
||||
export interface SharingDeps {
|
||||
ctx: PluginContext;
|
||||
shares: ShareRepository;
|
||||
rooms: RoomRepository;
|
||||
directory: Directory;
|
||||
live: LiveSessions;
|
||||
hub: CollabRoomHub;
|
||||
store: CollabRuntimeStore;
|
||||
/** /resolve/:linkToken, 30 a minute per IP. */
|
||||
resolveLimiter: RateLimiter;
|
||||
/** Room guest links, 60 a minute per IP. */
|
||||
guestLimiter: RateLimiter;
|
||||
isSharingEnabledForHost: (hostId: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const;
|
||||
|
||||
/** Where SSH shares are joined: the terminal's own socket. */
|
||||
export const TERMINAL_WS_PATH = "/plugin-ws/ssh-terminal/terminal";
|
||||
/** Where remote desktop viewers connect. */
|
||||
export const DISPLAY_WS_PATH = "/plugin-ws/remote-desktop/display";
|
||||
|
||||
export function clientIp(req: {
|
||||
ip?: string;
|
||||
socket?: { remoteAddress?: string };
|
||||
}): string {
|
||||
return req.ip || req.socket?.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/** The acting user, which core set from the authenticated request. */
|
||||
export function actorOf(ctx: PluginContext): string {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("No acting user");
|
||||
return userId;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { SharingDeps } from "./deps.js";
|
||||
import { createLiveSessions, isSharingEnabledForHost } from "./live.js";
|
||||
import { createRateLimiter } from "./rate-limit.js";
|
||||
import {
|
||||
createDirectory,
|
||||
createRoomRepository,
|
||||
createShareRepository,
|
||||
type CoreRefs,
|
||||
type Tables,
|
||||
} from "./repositories.js";
|
||||
import { CollabRoomHub } from "./room-hub.js";
|
||||
import { createRoomActions, registerRoomRoutes } from "./room-routes.js";
|
||||
import { CollabRuntimeStore } from "./runtime-store.js";
|
||||
import {
|
||||
SESSION_GUESTS_KEY,
|
||||
createSessionGuests,
|
||||
createSessionSharingService,
|
||||
} from "./services.js";
|
||||
import { registerShareRoutes } from "./share-routes.js";
|
||||
import { roomMembers, rooms, shareParticipants, shares } from "./tables.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const tables: Tables = {
|
||||
shares: await ctx.db.define(shares),
|
||||
participants: await ctx.db.define(shareParticipants),
|
||||
rooms: await ctx.db.define(rooms),
|
||||
members: await ctx.db.define(roomMembers),
|
||||
};
|
||||
const refs = () => ctx.db.refs<CoreRefs>();
|
||||
|
||||
const directory = createDirectory(ctx.db, refs);
|
||||
const store = new CollabRuntimeStore(ctx.log);
|
||||
const hub = new CollabRoomHub(store);
|
||||
const resolveLimiter = createRateLimiter({
|
||||
windowMs: 60_000,
|
||||
maxAttempts: 30,
|
||||
});
|
||||
const guestLimiter = createRateLimiter({ windowMs: 60_000, maxAttempts: 60 });
|
||||
ctx.disposables.add(() => {
|
||||
hub.dispose();
|
||||
resolveLimiter.dispose();
|
||||
guestLimiter.dispose();
|
||||
return store.close();
|
||||
});
|
||||
|
||||
const deps: SharingDeps = {
|
||||
ctx,
|
||||
shares: createShareRepository(ctx.db, tables),
|
||||
rooms: createRoomRepository(ctx.db, tables, refs),
|
||||
directory,
|
||||
live: createLiveSessions(ctx),
|
||||
hub,
|
||||
store,
|
||||
resolveLimiter,
|
||||
guestLimiter,
|
||||
isSharingEnabledForHost: (hostId) =>
|
||||
isSharingEnabledForHost(ctx, directory.hostExists, hostId),
|
||||
};
|
||||
|
||||
const actions = createRoomActions(deps);
|
||||
store.onEvent(actions.onRuntimeEvent);
|
||||
|
||||
// Guest links carry their own token, checked in the handlers.
|
||||
const router = ctx.http.router<Router>({
|
||||
public: ["/resolve/:linkToken", "/guest/:token"],
|
||||
});
|
||||
registerShareRoutes(router, deps);
|
||||
registerRoomRoutes(router, deps, actions);
|
||||
|
||||
ctx.services.provide("sessions.sharing", createSessionSharingService(deps));
|
||||
ctx.registry.provide(SESSION_GUESTS_KEY, createSessionGuests(deps));
|
||||
|
||||
ctx.log.info("Session sharing mounted at /plugin-api/session-sharing");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { LiveProtocol } from "./repositories.js";
|
||||
|
||||
export const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
|
||||
/** What every sessions.live provider reports about one session. */
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* sessions.live v1, keyed by session type. ssh-terminal provides "ssh";
|
||||
* remote-desktop provides "rdp", "vnc" and "telnet". Through a service handle
|
||||
* every method answers with a promise. The control methods are the terminal's
|
||||
* (a remote desktop viewer cannot be kicked or handed control), and
|
||||
* createViewerToken is remote desktop's: a join token for a viewer.
|
||||
*/
|
||||
export interface LiveSessionProvider {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
ownerEndSession?: (sessionId: string, reason: string) => void;
|
||||
disconnectParticipants?: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
) => number;
|
||||
setRoomShareControl?: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
) => void;
|
||||
listGuests?: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
) => { label: string | null }[];
|
||||
createViewerToken?: (sessionId: string, readOnly: boolean) => string;
|
||||
}
|
||||
|
||||
type Async<T> = {
|
||||
[K in keyof T]-?: T[K] extends ((...args: infer A) => infer R) | undefined
|
||||
? (...args: A) => Promise<R>
|
||||
: never;
|
||||
};
|
||||
|
||||
export type LiveSessions = ReturnType<typeof createLiveSessions>;
|
||||
|
||||
/**
|
||||
* Reaches live sessions through sessions.live as the acting user. A caller
|
||||
* with no actor (a public guest route) passes the share owner, who owns the
|
||||
* session being looked at.
|
||||
*/
|
||||
export function createLiveSessions(ctx: PluginContext) {
|
||||
async function run<T>(
|
||||
asUser: string | undefined,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (asUser && ctx.currentActor() !== asUser) {
|
||||
return ctx.asUser(asUser, fn);
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
|
||||
function provider(protocol: string): Async<LiveSessionProvider> | null {
|
||||
const handle = ctx.services.get<Async<LiveSessionProvider>>(
|
||||
LIVE_SESSIONS_SERVICE,
|
||||
{ provider: protocol },
|
||||
);
|
||||
return "getSession" in handle ? handle : null;
|
||||
}
|
||||
|
||||
async function getSession(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
asUser?: string,
|
||||
): Promise<LiveSessionInfo | null> {
|
||||
const live = provider(protocol);
|
||||
if (!live) return null;
|
||||
try {
|
||||
return (await run(asUser, () => live.getSession(sessionId))) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getSession,
|
||||
|
||||
async isLive(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
asUser?: string,
|
||||
): Promise<boolean> {
|
||||
const session = await getSession(protocol, sessionId, asUser);
|
||||
return !!session?.isConnected;
|
||||
},
|
||||
|
||||
async isOwnedBy(
|
||||
protocol: LiveProtocol,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const session = await getSession(protocol, sessionId);
|
||||
return !!session && session.isConnected && session.userId === userId;
|
||||
},
|
||||
|
||||
/** Anonymous link guests on one share; empty when the type cannot tell. */
|
||||
async listGuests(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
asUser?: string,
|
||||
): Promise<{ label: string | null }[]> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("listGuests" in live)) return [];
|
||||
try {
|
||||
return (
|
||||
(await run(asUser, () => live.listGuests(sessionId, shareId))) ?? []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/** Ends the session for every guest. Best effort, like before. */
|
||||
async ownerEndSession(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
asUser?: string,
|
||||
): Promise<void> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("ownerEndSession" in live)) return;
|
||||
await run(asUser, () => live.ownerEndSession(sessionId, reason)).catch(
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
|
||||
async disconnectParticipants(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
asUser?: string,
|
||||
): Promise<void> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("disconnectParticipants" in live)) return;
|
||||
await run(asUser, () =>
|
||||
live.disconnectParticipants(sessionId, shareId, options),
|
||||
).catch(() => {});
|
||||
},
|
||||
|
||||
async setRoomShareControl(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
asUser?: string,
|
||||
): Promise<void> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("setRoomShareControl" in live)) return;
|
||||
await run(asUser, () =>
|
||||
live.setRoomShareControl(sessionId, shareId, controllerUserId),
|
||||
).catch(() => {});
|
||||
},
|
||||
|
||||
/** A viewer token for a remote desktop session. Throws while unavailable. */
|
||||
async createViewerToken(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
readOnly: boolean,
|
||||
asUser?: string,
|
||||
): Promise<string> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("createViewerToken" in live)) {
|
||||
throw new Error(`No ${protocol} session provider is running`);
|
||||
}
|
||||
return run(asUser, () => live.createViewerToken(sessionId, readOnly));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether sessions on a host may be shared: the admin switch first, then the
|
||||
* host's own setting. Both default to on. A host that does not exist cannot
|
||||
* be shared.
|
||||
*/
|
||||
export async function isSharingEnabledForHost(
|
||||
ctx: PluginContext,
|
||||
hostExists: (hostId: number) => Promise<boolean>,
|
||||
hostId: number,
|
||||
): Promise<boolean> {
|
||||
if ((await ctx.settings.get<boolean>("globallyEnabled")) === false) {
|
||||
return false;
|
||||
}
|
||||
if (!(await hostExists(hostId))) return false;
|
||||
return (
|
||||
(await ctx.settings.getHost<boolean>(hostId, "allowSessionSharing")) !==
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A per-IP fixed window, for the public guest endpoints. `dispose` stops the
|
||||
* sweep that forgets old windows.
|
||||
*/
|
||||
export function createRateLimiter(options: {
|
||||
windowMs: number;
|
||||
maxAttempts: number;
|
||||
}) {
|
||||
const attempts = new Map<string, { count: number; windowStart: number }>();
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of attempts) {
|
||||
if (now - entry.windowStart > options.windowMs) attempts.delete(ip);
|
||||
}
|
||||
}, 5 * options.windowMs);
|
||||
sweep.unref();
|
||||
|
||||
return {
|
||||
/** Counts an attempt and says whether this IP is over the limit. */
|
||||
isLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > options.windowMs) {
|
||||
attempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > options.maxAttempts;
|
||||
},
|
||||
dispose(): void {
|
||||
clearInterval(sweep);
|
||||
attempts.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RateLimiter = ReturnType<typeof createRateLimiter>;
|
||||
@@ -0,0 +1,591 @@
|
||||
import { and, desc, eq, gt, inArray, isNull } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
|
||||
// drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type ShareType = "link" | "user" | "room";
|
||||
export type PermissionLevel = "read-only" | "read-write";
|
||||
export type LiveProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
export interface ShareRecord {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId: string | null;
|
||||
shareType: string;
|
||||
targetUserId: string | null;
|
||||
linkToken: string | null;
|
||||
permissionLevel: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastJoinedAt: string | null;
|
||||
joinCount: number;
|
||||
}
|
||||
|
||||
export interface ShareCreateInput {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string | null;
|
||||
shareType: ShareType;
|
||||
targetUserId?: string | null;
|
||||
linkToken?: string | null;
|
||||
permissionLevel: PermissionLevel;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface RoomRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkToken: string | null;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RoomMemberRecord {
|
||||
id: number;
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: string;
|
||||
addedBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RoomMemberWithUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RoomStage {
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
}
|
||||
|
||||
export interface Tables {
|
||||
shares: Table;
|
||||
participants: Table;
|
||||
rooms: Table;
|
||||
members: Table;
|
||||
}
|
||||
|
||||
export interface CoreRefs {
|
||||
users: Table;
|
||||
hosts: Table;
|
||||
roles: Table;
|
||||
userRoles: Table;
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
/** Not revoked and not expired. Expiry is compared as ISO text, as before. */
|
||||
function activeShare(shares: Table, now: string) {
|
||||
return and(isNull(shares.revokedAt), gt(shares.expiresAt, now));
|
||||
}
|
||||
|
||||
export type ShareRepository = ReturnType<typeof createShareRepository>;
|
||||
|
||||
export function createShareRepository(db: PluginDatabase, tables: Tables) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
const { shares, participants } = tables;
|
||||
|
||||
async function findById(id: string): Promise<ShareRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(eq(shares.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
|
||||
async create(input: ShareCreateInput): Promise<ShareRecord> {
|
||||
await (await client()).insert(shares).values({
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
protocol: input.protocol,
|
||||
sessionId: input.sessionId,
|
||||
tabInstanceId: input.tabInstanceId ?? null,
|
||||
shareType: input.shareType,
|
||||
targetUserId: input.targetUserId ?? null,
|
||||
linkToken: input.linkToken ?? null,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
await db.persist();
|
||||
return (await findById(input.id))!;
|
||||
},
|
||||
|
||||
async findActiveById(
|
||||
id: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.id, id), activeShare(shares, now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async findByLinkToken(
|
||||
linkToken: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.linkToken, linkToken), activeShare(shares, now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async findActiveSharesForHost(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord[]> {
|
||||
return (await client())
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(
|
||||
and(
|
||||
eq(shares.hostId, hostId),
|
||||
eq(shares.ownerUserId, ownerUserId),
|
||||
activeShare(shares, now),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Active user shares aimed at this user. */
|
||||
async findSharesTargetingUser(
|
||||
userId: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord[]> {
|
||||
return (await client())
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(
|
||||
and(
|
||||
eq(shares.shareType, "user"),
|
||||
eq(shares.targetUserId, userId),
|
||||
activeShare(shares, now),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Revokes a share the given user owns. False when it is not theirs. */
|
||||
async revoke(shareId: string, ownerUserId: string): Promise<boolean> {
|
||||
const share = await findById(shareId);
|
||||
if (!share || share.ownerUserId !== ownerUserId) return false;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(shares)
|
||||
.set({ revokedAt: nowIso() })
|
||||
.where(eq(shares.id, shareId));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async revokeAny(shareId: string): Promise<boolean> {
|
||||
if (!(await findById(shareId))) return false;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(shares)
|
||||
.set({ revokedAt: nowIso() })
|
||||
.where(eq(shares.id, shareId));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async touchUsage(shareId: string): Promise<void> {
|
||||
const current = await findById(shareId);
|
||||
if (!current) return;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(shares)
|
||||
.set({
|
||||
lastJoinedAt: nowIso(),
|
||||
joinCount: (current.joinCount ?? 0) + 1,
|
||||
})
|
||||
.where(eq(shares.id, shareId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async recordParticipantJoin(
|
||||
shareId: string,
|
||||
userId: string | null,
|
||||
guestLabel: string | null,
|
||||
): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.insert(participants)
|
||||
.values({ shareId, userId, guestLabel });
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RoomRepository = ReturnType<typeof createRoomRepository>;
|
||||
|
||||
export function createRoomRepository(
|
||||
db: PluginDatabase,
|
||||
tables: Tables,
|
||||
refs: () => Promise<CoreRefs>,
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
const { rooms, members } = tables;
|
||||
|
||||
async function findById(id: string): Promise<RoomRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function findMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<RoomMemberRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(members)
|
||||
.where(and(eq(members.roomId, roomId), eq(members.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function updateStage(roomId: string, stage: RoomStage): Promise<void> {
|
||||
await (await client()).update(rooms).set(stage).where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
findMember,
|
||||
updateStage,
|
||||
|
||||
async createRoom(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
}): Promise<RoomRecord> {
|
||||
await (await client()).insert(rooms).values(input);
|
||||
await db.persist();
|
||||
return (await findById(input.id))!;
|
||||
},
|
||||
|
||||
/** The live room whose stage points at this share, if any. */
|
||||
async findByStageShareId(shareId: string): Promise<RoomRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.stageShareId, shareId), isNull(rooms.endedAt)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async findByGuestToken(token: string): Promise<RoomRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.guestLinkToken, token), isNull(rooms.endedAt)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(rooms)
|
||||
.set({ guestLinkToken: token })
|
||||
.where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listForUser(userId: string): Promise<RoomRecord[]> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ room: rooms })
|
||||
.from(members)
|
||||
.innerJoin(rooms, eq(members.roomId, rooms.id))
|
||||
.where(and(eq(members.userId, userId), isNull(rooms.endedAt)))
|
||||
.orderBy(desc(rooms.createdAt));
|
||||
return rows.map((row: { room: RoomRecord }) => row.room);
|
||||
},
|
||||
|
||||
async addMember(input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: "host" | "member";
|
||||
addedBy: string | null;
|
||||
}): Promise<boolean> {
|
||||
if (await findMember(input.roomId, input.userId)) return false;
|
||||
await (await client()).insert(members).values(input);
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.delete(members)
|
||||
.where(and(eq(members.roomId, roomId), eq(members.userId, userId)));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listMembers(roomId: string): Promise<RoomMemberWithUser[]> {
|
||||
const rows: RoomMemberRecord[] = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(members)
|
||||
.where(eq(members.roomId, roomId));
|
||||
const names = await usernames(rows.map((row) => row.userId));
|
||||
return rows
|
||||
.filter((row) => names.has(row.userId))
|
||||
.map((row) => ({
|
||||
userId: row.userId,
|
||||
username: names.get(row.userId)!,
|
||||
roomRole: row.roomRole,
|
||||
createdAt: row.createdAt,
|
||||
}))
|
||||
.sort((a, b) => a.username.localeCompare(b.username));
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the stage only if it still points at `expectedShareId`, so two
|
||||
* members taking the stage at once cannot both win.
|
||||
*/
|
||||
async replaceStage(
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: RoomStage,
|
||||
): Promise<boolean> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(rooms)
|
||||
.set(stage)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, roomId),
|
||||
expectedShareId
|
||||
? eq(rooms.stageShareId, expectedShareId)
|
||||
: isNull(rooms.stageShareId),
|
||||
),
|
||||
);
|
||||
const after = await findById(roomId);
|
||||
const changed = !!after && after.stageShareId === stage.stageShareId;
|
||||
if (changed) await db.persist();
|
||||
return changed;
|
||||
},
|
||||
|
||||
async clearStage(roomId: string): Promise<void> {
|
||||
await updateStage(roomId, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
|
||||
async endRoom(roomId: string): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(rooms)
|
||||
.set({
|
||||
endedAt: nowIso(),
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
})
|
||||
.where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
// Not left to the foreign key: SQLite only cascades with foreign_keys on.
|
||||
await drizzle.delete(members).where(eq(members.roomId, roomId));
|
||||
await drizzle.delete(rooms).where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
|
||||
async function usernames(ids: string[]): Promise<Map<string, string>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
const { users } = await refs();
|
||||
const rows: Array<{ id: string; username: string }> = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(inArray(users.id, ids));
|
||||
return new Map(rows.map((row) => [row.id, row.username]));
|
||||
}
|
||||
}
|
||||
|
||||
export type Directory = ReturnType<typeof createDirectory>;
|
||||
|
||||
/** Users and roles, read through ctx.db.refs, for names and invites. */
|
||||
export function createDirectory(
|
||||
db: PluginDatabase,
|
||||
refs: () => Promise<CoreRefs>,
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async username(userId: string): Promise<string> {
|
||||
const { users } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return rows[0]?.username ?? userId;
|
||||
},
|
||||
|
||||
async userExists(userId: string): Promise<boolean> {
|
||||
const { users } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async isAdmin(userId: string): Promise<boolean> {
|
||||
const { users } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return !!rows[0]?.isAdmin;
|
||||
},
|
||||
|
||||
async hostIdsBySyncId(syncId: string): Promise<number[]> {
|
||||
const { hosts } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.syncId, syncId));
|
||||
return rows.map((row: { id: number }) => row.id);
|
||||
},
|
||||
|
||||
async hostExists(hostId: number): Promise<boolean> {
|
||||
const { hosts } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async roleExists(roleId: number): Promise<boolean> {
|
||||
const { roles } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async roleUserIds(roleId: number): Promise<string[]> {
|
||||
const { userRoles } = await refs();
|
||||
const rows: Array<{ userId: string }> = await (
|
||||
await client()
|
||||
)
|
||||
.select({ userId: userRoles.userId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
return rows.map((row) => row.userId);
|
||||
},
|
||||
|
||||
async listUsers(): Promise<Array<{ id: string; username: string }>> {
|
||||
const { users } = await refs();
|
||||
return (await client())
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.orderBy(users.username);
|
||||
},
|
||||
|
||||
async listRoles(): Promise<
|
||||
Array<{ id: number; name: string; displayName: string | null }>
|
||||
> {
|
||||
const { roles } = await refs();
|
||||
return (await client())
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
displayName: roles.displayName,
|
||||
})
|
||||
.from(roles)
|
||||
.orderBy(roles.name);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import type { CollabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
const PRESENCE_HEARTBEAT_MS = 15_000;
|
||||
|
||||
export interface CollabRoomClient {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local WebSocket fan-out plus the runtime store's pub/sub for room events and
|
||||
* presence across instances. The terminal socket feeds subscribe and
|
||||
* unsubscribe through the sessions.sharing service.
|
||||
*/
|
||||
export class CollabRoomHub {
|
||||
private rooms = new Map<string, Set<CollabRoomClient>>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
|
||||
constructor(private readonly store: CollabRuntimeStore) {
|
||||
store.onEvent((roomId, message) => {
|
||||
if (
|
||||
"type" in message &&
|
||||
typeof message.type === "string" &&
|
||||
message.type.startsWith("collab_internal_")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.broadcastLocal(roomId, message);
|
||||
});
|
||||
this.heartbeat = setInterval(() => {
|
||||
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
|
||||
}, PRESENCE_HEARTBEAT_MS);
|
||||
this.heartbeat.unref();
|
||||
}
|
||||
|
||||
subscribe(roomId: string, client: CollabRoomClient): void {
|
||||
let clients = this.rooms.get(roomId);
|
||||
if (!clients) {
|
||||
clients = new Set();
|
||||
this.rooms.set(roomId, clients);
|
||||
}
|
||||
for (const existing of clients) {
|
||||
if (existing.ws === client.ws) return;
|
||||
}
|
||||
clients.add(client);
|
||||
void this.refreshPresence(roomId);
|
||||
}
|
||||
|
||||
/** Drops the socket from one room, or from every room when roomId is omitted. */
|
||||
unsubscribe(ws: WebSocket, roomId?: string): void {
|
||||
for (const [id, clients] of this.rooms) {
|
||||
if (roomId && id !== roomId) continue;
|
||||
let removed = false;
|
||||
for (const client of clients) {
|
||||
if (client.ws === ws) {
|
||||
clients.delete(client);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (clients.size === 0) this.rooms.delete(id);
|
||||
if (removed) void this.refreshPresence(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(roomId: string, message: object): void {
|
||||
this.broadcastLocal(roomId, message);
|
||||
void this.store.publish(roomId, message);
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
return this.store.onlineUsers(roomId, this.localOnlineUsers(roomId));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearInterval(this.heartbeat);
|
||||
this.rooms.clear();
|
||||
}
|
||||
|
||||
private broadcastLocal(roomId: string, message: object): void {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
if (client.ws.readyState !== client.ws.OPEN) continue;
|
||||
try {
|
||||
client.ws.send(payload);
|
||||
} catch {
|
||||
/* keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private localOnlineUsers(
|
||||
roomId: string,
|
||||
): Array<{ userId: string; username: string }> {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const client of clients) {
|
||||
seen.set(client.userId, client.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
private async refreshPresence(roomId: string): Promise<void> {
|
||||
const localUsers = this.localOnlineUsers(roomId);
|
||||
await this.store.updatePresence(roomId, localUsers);
|
||||
this.broadcast(roomId, {
|
||||
type: "collab_online",
|
||||
roomId,
|
||||
users: await this.onlineUsers(roomId),
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,289 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createClient } from "redis";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface CollabControlRequest {
|
||||
userId: string;
|
||||
username: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
interface PresenceEntry {
|
||||
instanceId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface EventEnvelope {
|
||||
source: string;
|
||||
roomId: string;
|
||||
message: object;
|
||||
}
|
||||
|
||||
const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab";
|
||||
const EVENT_CHANNEL = `${KEY_PREFIX}:events`;
|
||||
const PRESENCE_TTL_MS = 45_000;
|
||||
const STATE_TTL_SECONDS = 12 * 60 * 60;
|
||||
const CONNECT_RETRY_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Room state that has to be shared between Termix instances: who controls a
|
||||
* stage, pending control requests, presence and room events. Redis-backed when
|
||||
* REDIS_URL is set, with local state for a single instance.
|
||||
*/
|
||||
export class CollabRuntimeStore {
|
||||
constructor(private readonly log: PluginLogger) {}
|
||||
|
||||
private readonly instanceId = crypto.randomUUID();
|
||||
private publisher: ReturnType<typeof createClient> | null = null;
|
||||
private subscriber: ReturnType<typeof createClient> | null = null;
|
||||
private connecting: Promise<boolean> | null = null;
|
||||
private nextConnectAttempt = 0;
|
||||
private eventListeners = new Set<(roomId: string, message: object) => void>();
|
||||
private localControllers = new Map<string, string>();
|
||||
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
|
||||
private presenceMembers = new Map<string, Set<string>>();
|
||||
|
||||
onEvent(listener: (roomId: string, message: object) => void): void {
|
||||
this.eventListeners.add(listener);
|
||||
void this.ensureConnected();
|
||||
}
|
||||
|
||||
async publish(roomId: string, message: object): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.publish(
|
||||
EVENT_CHANNEL,
|
||||
JSON.stringify({ source: this.instanceId, roomId, message }),
|
||||
)
|
||||
.catch((error) => this.logRedisFailure("publish", error));
|
||||
}
|
||||
|
||||
async getController(roomId: string): Promise<string | null> {
|
||||
if (await this.ensureConnected()) {
|
||||
const value = await this.publisher
|
||||
?.get(`${KEY_PREFIX}:controller:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("get_controller", error);
|
||||
return undefined;
|
||||
});
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return this.localControllers.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
async setController(roomId: string, userId: string | null): Promise<void> {
|
||||
if (userId) this.localControllers.set(roomId, userId);
|
||||
else this.localControllers.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:controller:${roomId}`;
|
||||
const operation = userId
|
||||
? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS })
|
||||
: this.publisher.del(key);
|
||||
await operation.catch((error) =>
|
||||
this.logRedisFailure("set_controller", error),
|
||||
);
|
||||
}
|
||||
|
||||
async listRequests(roomId: string): Promise<CollabControlRequest[]> {
|
||||
if (await this.ensureConnected()) {
|
||||
const values = await this.publisher
|
||||
?.hVals(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("list_requests", error);
|
||||
return null;
|
||||
});
|
||||
if (values) {
|
||||
return values
|
||||
.flatMap((value) => this.parse<CollabControlRequest>(value) ?? [])
|
||||
.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
|
||||
}
|
||||
}
|
||||
return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort(
|
||||
(a, b) => a.requestedAt.localeCompare(b.requestedAt),
|
||||
);
|
||||
}
|
||||
|
||||
async upsertRequest(
|
||||
roomId: string,
|
||||
request: CollabControlRequest,
|
||||
): Promise<void> {
|
||||
let requests = this.localRequests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
this.localRequests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:requests:${roomId}`;
|
||||
await this.publisher
|
||||
.multi()
|
||||
.hSet(key, request.userId, JSON.stringify(request))
|
||||
.expire(key, STATE_TTL_SECONDS)
|
||||
.exec()
|
||||
.catch((error) => this.logRedisFailure("upsert_request", error));
|
||||
}
|
||||
|
||||
async removeRequest(roomId: string, userId: string): Promise<void> {
|
||||
const requests = this.localRequests.get(roomId);
|
||||
requests?.delete(userId);
|
||||
if (requests?.size === 0) this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.hDel(`${KEY_PREFIX}:requests:${roomId}`, userId)
|
||||
.catch((error) => this.logRedisFailure("remove_request", error));
|
||||
}
|
||||
|
||||
async clearRequests(roomId: string): Promise<void> {
|
||||
this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.del(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => this.logRedisFailure("clear_requests", error));
|
||||
}
|
||||
|
||||
async updatePresence(
|
||||
roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
|
||||
const expiresAt = Date.now() + PRESENCE_TTL_MS;
|
||||
const current = new Set(
|
||||
users.map((user) =>
|
||||
JSON.stringify({
|
||||
instanceId: this.instanceId,
|
||||
...user,
|
||||
} satisfies PresenceEntry),
|
||||
),
|
||||
);
|
||||
const transaction = this.publisher.multi();
|
||||
if (previous.size > 0) transaction.zRem(key, Array.from(previous));
|
||||
for (const member of current) {
|
||||
transaction.zAdd(key, { score: expiresAt, value: member });
|
||||
}
|
||||
transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2);
|
||||
await transaction
|
||||
.exec()
|
||||
.then(() => {
|
||||
if (current.size > 0) this.presenceMembers.set(roomId, current);
|
||||
else this.presenceMembers.delete(roomId);
|
||||
})
|
||||
.catch((error) => this.logRedisFailure("update_presence", error));
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
localUsers: Array<{ userId: string; username: string }>,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return localUsers;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const now = Date.now();
|
||||
const transaction = this.publisher
|
||||
.multi()
|
||||
.zRemRangeByScore(key, 0, now)
|
||||
.zRangeByScore(key, now + 1, "+inf");
|
||||
const result = await transaction.exec().catch((error) => {
|
||||
this.logRedisFailure("online_users", error);
|
||||
return null;
|
||||
});
|
||||
const members = result?.[1] as string[] | undefined;
|
||||
if (!members) return localUsers;
|
||||
const seen = new Map<string, string>();
|
||||
for (const member of members) {
|
||||
const entry = this.parse<PresenceEntry>(member);
|
||||
if (entry) seen.set(entry.userId, entry.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.eventListeners.clear();
|
||||
this.nextConnectAttempt = Number.POSITIVE_INFINITY;
|
||||
await Promise.allSettled([
|
||||
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
|
||||
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL?.trim();
|
||||
if (!url) return false;
|
||||
if (this.publisher?.isReady && this.subscriber?.isReady) return true;
|
||||
if (Date.now() < this.nextConnectAttempt) return false;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = this.connect(url).finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async connect(url: string): Promise<boolean> {
|
||||
try {
|
||||
this.publisher = createClient({
|
||||
url,
|
||||
socket: { connectTimeout: 1500, reconnectStrategy: false },
|
||||
});
|
||||
this.subscriber = this.publisher.duplicate();
|
||||
this.publisher.on("error", (error) =>
|
||||
this.logRedisFailure("client", error),
|
||||
);
|
||||
this.subscriber.on("error", (error) =>
|
||||
this.logRedisFailure("subscriber", error),
|
||||
);
|
||||
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
|
||||
await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => {
|
||||
const envelope = this.parse<EventEnvelope>(raw);
|
||||
if (
|
||||
!envelope ||
|
||||
typeof envelope.source !== "string" ||
|
||||
typeof envelope.roomId !== "string" ||
|
||||
!envelope.message ||
|
||||
typeof envelope.message !== "object" ||
|
||||
envelope.source === this.instanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(envelope.roomId, envelope.message);
|
||||
} catch (error) {
|
||||
this.logRedisFailure("event_listener", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.log.info("Collaboration Redis runtime connected");
|
||||
this.nextConnectAttempt = 0;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
|
||||
this.logRedisFailure("connect", error);
|
||||
await Promise.allSettled([
|
||||
this.publisher?.disconnect(),
|
||||
this.subscriber?.disconnect(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parse<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private logRedisFailure(operation: string, error: unknown): void {
|
||||
this.log.warn(
|
||||
`Collaboration Redis runtime unavailable (${operation}), using local state: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import type { SharingDeps } from "./deps.js";
|
||||
import type { ShareRecord } from "./repositories.js";
|
||||
|
||||
/**
|
||||
* What the terminal consumes. The shapes match
|
||||
* plugins/ssh-terminal/src/backend/services.ts, which is the consumer's copy.
|
||||
*/
|
||||
export interface SharedSessionRef {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
}
|
||||
|
||||
/** sessions.sharing v1: signed-in joins. Every call runs as the acting user. */
|
||||
export interface SessionSharingV1 {
|
||||
authorizeJoin: (
|
||||
shareId: string,
|
||||
) => Promise<{ share: SharedSessionRef; displayName: string } | null>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
subscribeRoom: (roomId: string, socket: unknown) => Promise<boolean>;
|
||||
unsubscribeRoom: (socket: unknown, roomId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest joins through a share link or a room guest link, on ctx.registry as
|
||||
* SESSION_GUESTS_KEY. A guest has no user, so a service's per-call
|
||||
* permission check cannot apply: the token is the authority.
|
||||
*/
|
||||
export interface SessionGuestsV1 {
|
||||
resolve: (request: {
|
||||
shareToken?: string;
|
||||
roomGuestToken?: string;
|
||||
clientIp: string;
|
||||
}) => Promise<
|
||||
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
|
||||
>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SESSION_GUESTS_KEY = "session-sharing.guests";
|
||||
|
||||
function toRef(share: ShareRecord): SharedSessionRef {
|
||||
return {
|
||||
id: share.id,
|
||||
sessionId: share.sessionId,
|
||||
permissionLevel:
|
||||
share.permissionLevel === "read-write" ? "read-write" : "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionSharingService(
|
||||
deps: SharingDeps,
|
||||
): SessionSharingV1 {
|
||||
const { ctx, shares, rooms, directory, hub } = deps;
|
||||
|
||||
return {
|
||||
async authorizeJoin(shareId) {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return null;
|
||||
const share = await shares.findActiveById(shareId);
|
||||
if (!share || share.protocol !== "ssh") return null;
|
||||
|
||||
if (share.shareType === "user") {
|
||||
// A user share needs its target, who can still reach the host.
|
||||
if (share.targetUserId !== userId) return null;
|
||||
const access = await ctx.hosts.checkAccess(share.hostId, "connect");
|
||||
if (!access.hasAccess) return null;
|
||||
} else if (share.shareType === "room") {
|
||||
// Room membership is the authorization for a room stage: it is
|
||||
// read-only and never exposes host credentials or config.
|
||||
const room = await rooms.findByStageShareId(share.id);
|
||||
if (!room || !(await rooms.findMember(room.id, userId))) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
share: toRef(share),
|
||||
displayName: await directory.username(userId),
|
||||
};
|
||||
},
|
||||
|
||||
async recordJoin(shareId) {
|
||||
const userId = ctx.currentActor() ?? null;
|
||||
await shares.touchUsage(shareId);
|
||||
await shares.recordParticipantJoin(shareId, userId, null);
|
||||
},
|
||||
|
||||
async subscribeRoom(roomId, socket) {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return false;
|
||||
const room = await rooms.findById(roomId);
|
||||
if (!room || room.endedAt) return false;
|
||||
if (!(await rooms.findMember(roomId, userId))) return false;
|
||||
hub.subscribe(roomId, {
|
||||
ws: socket as WebSocket,
|
||||
userId,
|
||||
username: await directory.username(userId),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async unsubscribeRoom(socket, roomId) {
|
||||
hub.unsubscribe(socket as WebSocket, roomId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionGuests(deps: SharingDeps): SessionGuestsV1 {
|
||||
const { shares, rooms, guestLimiter } = deps;
|
||||
|
||||
/** Sharing still allowed, and an SSH share: the only kind the socket joins. */
|
||||
async function joinable(
|
||||
share: ShareRecord,
|
||||
): Promise<
|
||||
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
|
||||
> {
|
||||
if (share.protocol !== "ssh") {
|
||||
return { ok: false, reason: "Unsupported share protocol" };
|
||||
}
|
||||
if (!(await deps.isSharingEnabledForHost(share.hostId))) {
|
||||
return { ok: false, reason: "Session sharing is disabled for this host" };
|
||||
}
|
||||
return { ok: true, share: toRef(share) };
|
||||
}
|
||||
|
||||
return {
|
||||
async resolve({ shareToken, roomGuestToken, clientIp }) {
|
||||
if (shareToken) {
|
||||
// Guessing link tokens is limited the same way room tokens are.
|
||||
if (guestLimiter.isLimited(clientIp)) {
|
||||
return { ok: false, reason: "Too many requests" };
|
||||
}
|
||||
const share = await shares.findByLinkToken(shareToken);
|
||||
if (!share || share.shareType !== "link") {
|
||||
return { ok: false, reason: "Invalid or expired share link" };
|
||||
}
|
||||
return joinable(share);
|
||||
}
|
||||
|
||||
if (roomGuestToken) {
|
||||
if (guestLimiter.isLimited(clientIp)) {
|
||||
return { ok: false, reason: "Too many requests" };
|
||||
}
|
||||
const room = await rooms.findByGuestToken(roomGuestToken);
|
||||
const share = room?.stageShareId
|
||||
? await shares.findActiveById(room.stageShareId)
|
||||
: null;
|
||||
if (!share) return { ok: false, reason: "Nothing is being presented" };
|
||||
return joinable(share);
|
||||
}
|
||||
|
||||
return { ok: false, reason: "Authentication required" };
|
||||
},
|
||||
|
||||
async recordJoin(shareId) {
|
||||
await shares.touchUsage(shareId);
|
||||
await shares.recordParticipantJoin(shareId, null, "Guest");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import {
|
||||
DISPLAY_WS_PATH,
|
||||
PROTOCOLS,
|
||||
TERMINAL_WS_PATH,
|
||||
actorOf,
|
||||
clientIp,
|
||||
type SharingDeps,
|
||||
} from "./deps.js";
|
||||
import type { LiveProtocol, PermissionLevel } from "./repositories.js";
|
||||
|
||||
const DEFAULT_EXPIRY_HOURS = 24;
|
||||
const MAX_EXPIRY_HOURS = 24 * 30;
|
||||
|
||||
function computeExpiresAt(expiryHours: number | undefined): string {
|
||||
const hours = Math.min(
|
||||
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
||||
MAX_EXPIRY_HOURS,
|
||||
);
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The share routes. `/resolve/:linkToken` is public (an anonymous guest link);
|
||||
* the rest run behind core auth and the plugin's `use` permission, which the
|
||||
* caller applies to the router first.
|
||||
*/
|
||||
export function registerShareRoutes(router: Router, deps: SharingDeps): void {
|
||||
const { ctx, shares, directory, live, resolveLimiter } = deps;
|
||||
const requireUse = ctx.rbac.require("use") as never;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/resolve/{linkToken}:
|
||||
* get:
|
||||
* summary: Resolve a guest share link
|
||||
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: linkToken
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resolved share connection info
|
||||
* 404:
|
||||
* description: Link not found, expired, revoked, or sharing disabled
|
||||
* 429:
|
||||
* description: Too many requests
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (resolveLimiter.isLimited(clientIp(req))) {
|
||||
return res.status(429).json({ error: "Too many requests" });
|
||||
}
|
||||
|
||||
const linkToken = String(req.params.linkToken);
|
||||
const share = await shares.findByLinkToken(linkToken);
|
||||
if (!share || share.shareType !== "link") {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
if (!(await deps.isSharingEnabledForHost(share.hostId))) {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
|
||||
const protocol = share.protocol as LiveProtocol;
|
||||
if (!(await live.isLive(protocol, share.sessionId, share.ownerUserId))) {
|
||||
return res.status(404).json({ error: "Session is no longer active" });
|
||||
}
|
||||
|
||||
// Field by field on purpose: anonymous guests never see the host's
|
||||
// name, address, username or id.
|
||||
const response: {
|
||||
protocol: LiveProtocol;
|
||||
permissionLevel: PermissionLevel;
|
||||
wsPath: string;
|
||||
connectParams?: Record<string, string>;
|
||||
} = {
|
||||
protocol,
|
||||
permissionLevel: share.permissionLevel as PermissionLevel,
|
||||
// Advisory only: the client builds its own socket URL per deployment.
|
||||
wsPath:
|
||||
protocol === "ssh"
|
||||
? `${TERMINAL_WS_PATH}?shareToken=${encodeURIComponent(linkToken)}`
|
||||
: DISPLAY_WS_PATH,
|
||||
};
|
||||
|
||||
if (protocol !== "ssh") {
|
||||
response.connectParams = {
|
||||
token: await live.createViewerToken(
|
||||
protocol,
|
||||
share.sessionId,
|
||||
share.permissionLevel === "read-only",
|
||||
share.ownerUserId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await shares.touchUsage(share.id);
|
||||
await shares.recordParticipantJoin(share.id, null, "Guest");
|
||||
} catch {
|
||||
// Bookkeeping never fails the resolve.
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to resolve session share link",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to resolve share link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/create:
|
||||
* post:
|
||||
* summary: Create a session share (link or targeted user)
|
||||
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - hostId
|
||||
* - sessionId
|
||||
* - protocol
|
||||
* - shareType
|
||||
* - permissionLevel
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* sessionId:
|
||||
* type: string
|
||||
* tabInstanceId:
|
||||
* type: string
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [ssh, rdp, vnc, telnet]
|
||||
* shareType:
|
||||
* type: string
|
||||
* enum: [link, user]
|
||||
* targetUserId:
|
||||
* type: string
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [read-only, read-write]
|
||||
* expiryHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share created
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* 403:
|
||||
* description: Sharing disabled, or caller does not own the session
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post("/create", requireUse, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const {
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType,
|
||||
targetUserId,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (
|
||||
!hostId ||
|
||||
!sessionId ||
|
||||
!protocol ||
|
||||
!shareType ||
|
||||
!permissionLevel
|
||||
) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
if (!PROTOCOLS.includes(protocol)) {
|
||||
return res.status(400).json({ error: "Invalid protocol" });
|
||||
}
|
||||
if (!["link", "user"].includes(shareType)) {
|
||||
return res.status(400).json({ error: "Invalid shareType" });
|
||||
}
|
||||
if (!["read-only", "read-write"].includes(permissionLevel)) {
|
||||
return res.status(400).json({ error: "Invalid permissionLevel" });
|
||||
}
|
||||
if (shareType === "user" && !targetUserId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "targetUserId is required for user shares" });
|
||||
}
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
if (!(await deps.isSharingEnabledForHost(numericHostId))) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Session sharing is disabled for this host" });
|
||||
}
|
||||
if (!(await live.isOwnedBy(protocol, String(sessionId), userId))) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You do not own this live session" });
|
||||
}
|
||||
|
||||
if (shareType === "user") {
|
||||
const target = String(targetUserId);
|
||||
const access = (await directory.userExists(target))
|
||||
? await ctx.asUser(target, () =>
|
||||
ctx.hosts.checkAccess(numericHostId, "connect"),
|
||||
)
|
||||
: { hasAccess: false };
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await shares.create({
|
||||
id: crypto.randomUUID(),
|
||||
hostId: numericHostId,
|
||||
ownerUserId: userId,
|
||||
protocol,
|
||||
sessionId: String(sessionId),
|
||||
tabInstanceId: tabInstanceId ?? null,
|
||||
shareType,
|
||||
targetUserId: shareType === "user" ? String(targetUserId) : null,
|
||||
linkToken:
|
||||
shareType === "link"
|
||||
? crypto.randomBytes(24).toString("base64url")
|
||||
: null,
|
||||
permissionLevel,
|
||||
expiresAt: computeExpiresAt(expiryHours),
|
||||
});
|
||||
|
||||
res.json({
|
||||
shareId: created.id,
|
||||
linkToken: created.linkToken,
|
||||
expiresAt: created.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to create session share",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to create session share" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/host/{hostId}/active:
|
||||
* get:
|
||||
* summary: List active session shares for a host
|
||||
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active shares
|
||||
* 400:
|
||||
* description: Invalid host id
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/host/:hostId/active",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
if (!hostId || Number.isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
res.json({
|
||||
shares: await shares.findActiveSharesForHost(hostId, userId),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to list session shares",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list session shares" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/shared-with-me:
|
||||
* get:
|
||||
* summary: List live SSH sessions other users shared with the caller
|
||||
* description: Active user shares aimed at the caller whose session is still live, in the shape of /open-tabs/active-sessions.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shared sessions
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/shared-with-me",
|
||||
requireUse,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const result = [];
|
||||
for (const share of await shares.findSharesTargetingUser(userId)) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const session = await live.getSession("ssh", share.sessionId);
|
||||
if (!session?.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: session.id,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
tabInstanceId: session.tabInstanceId,
|
||||
isConnected: session.isConnected,
|
||||
createdAt: session.createdAt,
|
||||
isOwnSession: false,
|
||||
sharedByUsername: await directory.username(share.ownerUserId),
|
||||
permissionLevel: share.permissionLevel,
|
||||
shareId: share.id,
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to list shared sessions",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list shared sessions" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/directory:
|
||||
* get:
|
||||
* summary: Users and roles to share with or invite
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Users (id, username) and roles (id, name, displayName)
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get("/directory", requireUse, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
users: await directory.listUsers(),
|
||||
roles: await directory.listRoles(),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to list users and roles",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list users and roles" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/{shareId}:
|
||||
* delete:
|
||||
* summary: Revoke a session share
|
||||
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; remote desktop viewers are not force-disconnected.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share revoked
|
||||
* 403:
|
||||
* description: Not authorized to revoke this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.delete(
|
||||
"/:shareId",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const shareId = String(req.params.shareId);
|
||||
const share = await shares.findById(shareId);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
|
||||
let revoked = await shares.revoke(shareId, userId);
|
||||
if (!revoked && (await directory.isAdmin(userId))) {
|
||||
revoked = await shares.revokeAny(shareId);
|
||||
}
|
||||
if (!revoked) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Not authorized to revoke this share" });
|
||||
}
|
||||
|
||||
// guacamole-lite has no kick, so a revoked remote desktop link only
|
||||
// blocks new resolves until the viewer's own socket ends.
|
||||
if (share.protocol === "ssh") {
|
||||
await live.ownerEndSession(
|
||||
"ssh",
|
||||
share.sessionId,
|
||||
"Session share revoked by owner",
|
||||
share.ownerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to revoke session share",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to revoke session share" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/{shareId}/end:
|
||||
* post:
|
||||
* summary: End a shared session for all participants
|
||||
* description: Owner-only. Terminates the underlying session and notifies joined participants. Remote desktop viewers are not force-disconnected.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session ended
|
||||
* 403:
|
||||
* description: Not the owner of this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/:shareId/end",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const share = await shares.findById(String(req.params.shareId));
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
if (share.ownerUserId !== userId) {
|
||||
return res.status(403).json({ error: "Not the owner of this share" });
|
||||
}
|
||||
if (share.protocol === "ssh") {
|
||||
await live.ownerEndSession(
|
||||
"ssh",
|
||||
share.sessionId,
|
||||
"Session ended by owner",
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to end shared session",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to end shared session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/*
|
||||
* Adopted from core's session_shares, session_share_participants,
|
||||
* collab_rooms and collab_room_members, so column and index names are the
|
||||
* legacy ones and the rename carries every row across.
|
||||
*
|
||||
* Columns that point at a user or host but may be null or set null on delete,
|
||||
* and the links between these four tables, are plain columns here: the SDK
|
||||
* only references users and ssh_data, cascading. The adoption migrations keep
|
||||
* the legacy FOREIGN KEY clauses by hand, as fleets does.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A grant to join one live session: a link for anonymous guests, a user, or
|
||||
* a collab room's stage ("room").
|
||||
*/
|
||||
export const shares = adoptLegacyTable(
|
||||
"session_shares",
|
||||
defineTable(
|
||||
"shares",
|
||||
{
|
||||
id: varchar().primaryKey(),
|
||||
hostId: refHost(),
|
||||
ownerUserId: refUser(),
|
||||
protocol: text().notNull(),
|
||||
// The terminal's session id for SSH, guacd's connection id otherwise.
|
||||
// Neither is a row, so there is no foreign key.
|
||||
sessionId: varchar().notNull(),
|
||||
tabInstanceId: text(),
|
||||
shareType: text().notNull(),
|
||||
targetUserId: varchar(),
|
||||
linkToken: varchar().unique(),
|
||||
permissionLevel: text().notNull().default("read-only"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
expiresAt: text().notNull(),
|
||||
revokedAt: text(),
|
||||
lastJoinedAt: text(),
|
||||
joinCount: integer().notNull().default(0),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ name: "idx_session_shares_session_id", columns: ["sessionId"] },
|
||||
{ name: "idx_session_shares_host_id", columns: ["hostId"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Who joined through a share, for the owner's record. */
|
||||
export const shareParticipants = adoptLegacyTable(
|
||||
"session_share_participants",
|
||||
defineTable("share_participants", {
|
||||
id: id(),
|
||||
shareId: varchar().notNull(),
|
||||
userId: varchar(),
|
||||
guestLabel: text(),
|
||||
joinedAt: timestamp().notNull().defaultNow(),
|
||||
leftAt: text(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A collaboration room: members watching one stage, the live session the
|
||||
* current presenter shows through a "room" share.
|
||||
*/
|
||||
export const rooms = adoptLegacyTable(
|
||||
"collab_rooms",
|
||||
defineTable(
|
||||
"rooms",
|
||||
{
|
||||
id: varchar().primaryKey(),
|
||||
name: text().notNull(),
|
||||
ownerUserId: refUser(),
|
||||
persistent: boolean().notNull().default(false),
|
||||
presenterUserId: varchar(),
|
||||
stageProtocol: text(),
|
||||
stageHostId: integer(),
|
||||
stageShareId: varchar(),
|
||||
guestLinkToken: varchar(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
endedAt: text(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_collab_rooms_owner", columns: ["ownerUserId"] }],
|
||||
uniques: [
|
||||
{ name: "idx_collab_rooms_guest_token", columns: ["guestLinkToken"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const roomMembers = adoptLegacyTable(
|
||||
"collab_room_members",
|
||||
defineTable(
|
||||
"room_members",
|
||||
{
|
||||
id: id(),
|
||||
roomId: varchar().notNull(),
|
||||
userId: refUser(),
|
||||
roomRole: text().notNull().default("member"),
|
||||
addedBy: varchar(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_collab_room_members_room_user",
|
||||
columns: ["roomId", "userId"],
|
||||
},
|
||||
],
|
||||
indexes: [{ name: "idx_collab_room_members_user", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [shares, shareParticipants, rooms, roomMembers];
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { AlertCircle, Eye, Presentation } from "lucide-react";
|
||||
import { RemoteDisplay } from "./shared";
|
||||
import { GuestTerminalView } from "./SharedSessionView";
|
||||
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Anonymous guest page for a collab room (?view=collab-guest&token=...).
|
||||
* Guests have no account and no event channel, so they poll the public
|
||||
* resolve endpoint and remount the viewer whenever the stage share changes.
|
||||
*/
|
||||
export default function CollabGuestView() {
|
||||
const { t } = useTranslation();
|
||||
const token = new URLSearchParams(window.location.search).get("token");
|
||||
const [roomName, setRoomName] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<CollabGuestStage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const stageShareIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t("collab.guest.linkInvalid"));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await resolveCollabGuestStage(token);
|
||||
if (cancelled) return;
|
||||
setRoomName(result.roomName);
|
||||
setError(null);
|
||||
const nextShareId = result.stage?.shareId ?? null;
|
||||
// Tokens are minted per resolve; only swap the viewer on a real change.
|
||||
if (nextShareId !== stageShareIdRef.current) {
|
||||
stageShareIdRef.current = nextShareId;
|
||||
setStage(result.stage);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(t("collab.guest.linkInvalid"));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--bg-base)", color: "var(--foreground)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border text-sm shrink-0">
|
||||
<Presentation className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-semibold truncate min-w-0">
|
||||
{roomName ?? t("collab.guest.title")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-auto text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="size-3.5" />
|
||||
{t("sessionSharing.guestView.readOnlyBadge")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{error ? (
|
||||
<Note icon={<AlertCircle className="size-8" />} text={error} />
|
||||
) : !stage ? (
|
||||
<Note
|
||||
icon={<Presentation className="size-8" />}
|
||||
text={t("collab.guest.waiting")}
|
||||
/>
|
||||
) : stage.protocol === "ssh" ? (
|
||||
<GuestTerminalView
|
||||
key={stage.shareId}
|
||||
share={{ permissionLevel: "read-only" }}
|
||||
wsPath={stage.wsPath ?? ""}
|
||||
hideBadges
|
||||
/>
|
||||
) : stage.connectParams?.token ? (
|
||||
<RemoteDisplay
|
||||
key={stage.shareId}
|
||||
token={stage.connectParams.token}
|
||||
protocol={stage.protocol}
|
||||
isVisible
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
{icon}
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Check,
|
||||
Crown,
|
||||
Eye,
|
||||
Hand,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
UserMinus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@termix/plugin-sdk/ui";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { CollabControlRequest, CollabRoomDetail } from "./api";
|
||||
|
||||
interface CollabMembersSidebarProps {
|
||||
detail: CollabRoomDetail;
|
||||
onClose: () => void;
|
||||
onInvite: () => void;
|
||||
onControl: (userId: string | null) => Promise<void>;
|
||||
onDismissRequest: (userId: string) => Promise<void>;
|
||||
onRemoveMember: (userId: string) => Promise<void>;
|
||||
onRemoveGuests: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function CollabMembersSidebar({
|
||||
detail,
|
||||
onClose,
|
||||
onInvite,
|
||||
onControl,
|
||||
onDismissRequest,
|
||||
onRemoveMember,
|
||||
onRemoveGuests,
|
||||
}: CollabMembersSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [removing, setRemoving] = useState<{
|
||||
userId: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||||
const guests = detail.guests ?? [];
|
||||
const onlineIds = new Set(detail.online.map((user) => user.userId));
|
||||
const presenterUserId = detail.stage.presenterUserId;
|
||||
const canManageControl = detail.isHost || presenterUserId === detail.me;
|
||||
const canControl =
|
||||
canManageControl &&
|
||||
detail.stage.protocol === "ssh" &&
|
||||
!!detail.stage.shareId;
|
||||
|
||||
async function run(userId: string, action: () => Promise<void>) {
|
||||
setBusyUserId(userId);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setBusyUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function grant(request: CollabControlRequest) {
|
||||
await run(request.userId, async () => {
|
||||
await onControl(request.userId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="collab-members-sidebar"
|
||||
aria-label={t("collab.membersPanel")}
|
||||
className="absolute inset-y-0 right-0 z-30 flex h-full w-[min(20rem,100%)] shrink-0 flex-col border-l border-border bg-background shadow-xl lg:static lg:z-auto lg:shadow-none"
|
||||
>
|
||||
<header className="flex h-11 items-center gap-2 border-b border-border px-3">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
<h2 className="flex-1 text-sm font-semibold">
|
||||
{t("collab.membersWithCount", { count: detail.members.length })}
|
||||
</h2>
|
||||
{detail.isHost && (
|
||||
<Button size="sm" variant="outline" onClick={onInvite}>
|
||||
{t("collab.invite")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{canManageControl && (
|
||||
<section className="border-b border-border p-3" aria-live="polite">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Hand className="size-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide">
|
||||
{t("collab.controlQueue")}
|
||||
</h3>
|
||||
{detail.controlRequests.length > 0 && (
|
||||
<Badge variant="default" className="ml-auto text-xs">
|
||||
{detail.controlRequests.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{detail.controlRequests.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("collab.noControlRequests")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{detail.controlRequests.map((request) => (
|
||||
<div
|
||||
key={request.userId}
|
||||
className="flex items-center gap-2 border border-border p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{request.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(request.requestedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
aria-label={t("collab.grantControlTo", {
|
||||
name: request.username,
|
||||
})}
|
||||
disabled={busyUserId === request.userId}
|
||||
onClick={() => void grant(request)}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
aria-label={t("collab.dismissControlRequest", {
|
||||
name: request.username,
|
||||
})}
|
||||
disabled={busyUserId === request.userId}
|
||||
onClick={() =>
|
||||
void run(request.userId, () =>
|
||||
onDismissRequest(request.userId),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<h3 className="sr-only">{t("collab.members")}</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{detail.members.map((member) => {
|
||||
const isOnline = onlineIds.has(member.userId);
|
||||
const isPresenter = member.userId === presenterUserId;
|
||||
const hasControl = member.userId === detail.controllerUserId;
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
className="group flex min-h-11 items-center gap-2 border border-transparent px-2 py-1.5 hover:border-border hover:bg-muted/40"
|
||||
>
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${
|
||||
isOnline ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
aria-label={t(isOnline ? "collab.online" : "collab.offline")}
|
||||
role="img"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{member.username}
|
||||
{member.userId === detail.me && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{t("collab.you")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{member.roomRole === "host" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Crown className="size-3" /> {t("collab.hostBadge")}
|
||||
</span>
|
||||
)}
|
||||
{isPresenter && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MonitorUp className="size-3" />
|
||||
{t("collab.presenterBadge")}
|
||||
</span>
|
||||
)}
|
||||
{hasControl && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MousePointerClick className="size-3" />
|
||||
{t("collab.controlBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canControl && !isPresenter && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant={hasControl ? "default" : "ghost"}
|
||||
aria-label={t(
|
||||
hasControl
|
||||
? "collab.revokeControl"
|
||||
: "collab.grantControlTo",
|
||||
{ name: member.username },
|
||||
)}
|
||||
disabled={busyUserId === member.userId}
|
||||
onClick={() =>
|
||||
void run(member.userId, () =>
|
||||
onControl(hasControl ? null : member.userId),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{detail.isHost && member.userId !== detail.room.ownerUserId && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t("collab.removeMember", {
|
||||
name: member.username,
|
||||
})}
|
||||
onClick={() =>
|
||||
setRemoving({
|
||||
userId: member.userId,
|
||||
username: member.username,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserMinus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{guests.length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-2">
|
||||
<div className="flex items-center gap-2 px-2 pb-1">
|
||||
<Eye className="size-3.5 text-muted-foreground" />
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide">
|
||||
{t("collab.guestsWithCount", { count: guests.length })}
|
||||
</h3>
|
||||
{detail.isHost && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={busyUserId === "guests"}
|
||||
onClick={() => void run("guests", onRemoveGuests)}
|
||||
>
|
||||
{t("collab.removeGuests")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{guests.map((guest, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-h-9 items-center gap-2 px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="size-2 shrink-0 rounded-full bg-green-500" />
|
||||
<span className="truncate">
|
||||
{guest.label || t("collab.anonymousGuest")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(removing)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoving(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("collab.removeMemberTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("collab.removeMemberDescription", {
|
||||
name: removing?.username,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (removing) void onRemoveMember(removing.userId);
|
||||
}}
|
||||
>
|
||||
{t("collab.deleteMember")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import { Badge } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { CollabRoom } from "./api";
|
||||
import type { MeetingBackend } from "./meeting-backend";
|
||||
import { getErrorMessage } from "./shared";
|
||||
|
||||
export function CollabPanel({
|
||||
onOpenRoom,
|
||||
backend,
|
||||
}: {
|
||||
onOpenRoom: (room: CollabRoom) => void;
|
||||
backend: MeetingBackend;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { createCollabRoom, listCollabRooms } = backend.api;
|
||||
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [persistent, setPersistent] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const result = await listCollabRooms();
|
||||
setRooms(result.rooms ?? []);
|
||||
} catch {
|
||||
/* the list stays as-is */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [backend]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 30000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { room } = await createCollabRoom(name.trim(), persistent);
|
||||
toast.success(t("collab.created"));
|
||||
setCreateOpen(false);
|
||||
setName("");
|
||||
setPersistent(false);
|
||||
await refresh();
|
||||
onOpenRoom(room);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs flex-1 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand font-semibold hover:bg-accent-brand/20"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("collab.createRoom")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => void refresh()}
|
||||
aria-label={t("common.refresh")}
|
||||
title={t("common.refresh")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
|
||||
{t("collab.noRooms")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{rooms.map((room) => (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => onOpenRoom(room)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
|
||||
>
|
||||
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-xs truncate">{room.name}</span>
|
||||
{room.presenterUserId && (
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-red-500 shrink-0"
|
||||
role="img"
|
||||
aria-label={t("collab.presenterBadge")}
|
||||
title={t("collab.presenterBadge")}
|
||||
/>
|
||||
)}
|
||||
{room.persistent && (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0">
|
||||
{t("collab.persistentRoom")}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label htmlFor="collab-room-name" className="text-xs font-medium">
|
||||
{t("collab.roomName")}
|
||||
</label>
|
||||
<Input
|
||||
id="collab-room-name"
|
||||
placeholder={t("collab.roomName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreate();
|
||||
}}
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={persistent}
|
||||
onChange={(e) => setPersistent(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
{t("collab.persistentRoom")}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
{t("collab.persistentRoomHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !name.trim()}
|
||||
>
|
||||
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
ParticipantsBadge,
|
||||
type SessionParticipantInfo,
|
||||
} from "./SharedSessionView";
|
||||
|
||||
interface ParticipantsOverlayProps {
|
||||
subscribe: (
|
||||
listener: (message: { type: string; [key: string]: unknown }) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export function ParticipantsOverlay({ subscribe }: ParticipantsOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe((message) => {
|
||||
if (
|
||||
message.type === "participants" &&
|
||||
Array.isArray(message.participants)
|
||||
) {
|
||||
setParticipants(message.participants as SessionParticipantInfo[]);
|
||||
} else if (message.type === "session_closed") {
|
||||
setParticipants([]);
|
||||
}
|
||||
}),
|
||||
[subscribe],
|
||||
);
|
||||
|
||||
return (
|
||||
<ParticipantsBadge
|
||||
participants={participants}
|
||||
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
createSessionShare,
|
||||
getActiveSessionShares,
|
||||
revokeSessionShare,
|
||||
type SessionShareProtocol,
|
||||
type SessionSharePermissionLevel,
|
||||
type SessionShareRecord,
|
||||
getDirectory,
|
||||
type SessionOrigin,
|
||||
} from "./api";
|
||||
import { guestViewUrl } from "./meeting-backend";
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ key: "oneHour", hours: 1 },
|
||||
{ key: "oneDay", hours: 24 },
|
||||
{ key: "sevenDays", hours: 24 * 7 },
|
||||
{ key: "thirtyDays", hours: 24 * 30 },
|
||||
{ key: "custom", hours: undefined },
|
||||
] as const;
|
||||
|
||||
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
|
||||
|
||||
export function ShareSessionModal({
|
||||
open,
|
||||
onClose,
|
||||
hostId,
|
||||
sessionId,
|
||||
protocol,
|
||||
tabInstanceId,
|
||||
origin,
|
||||
resolvePublicUrl,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
hostId: number;
|
||||
sessionId: string | null;
|
||||
protocol: SessionShareProtocol;
|
||||
tabInstanceId?: string;
|
||||
origin?: SessionOrigin;
|
||||
resolvePublicUrl?: (origin?: SessionOrigin) => Promise<string | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<"link" | "user">("link");
|
||||
const [permissionLevel, setPermissionLevel] =
|
||||
useState<SessionSharePermissionLevel>("read-only");
|
||||
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
|
||||
const [customHours, setCustomHours] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [createdLink, setCreatedLink] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<SessionShareRecord[]>([]);
|
||||
const [sharesLoaded, setSharesLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode("link");
|
||||
setPermissionLevel("read-only");
|
||||
setExpiryPreset("oneDay");
|
||||
setCustomHours("");
|
||||
setSearch("");
|
||||
setSelectedUserId(null);
|
||||
setCreatedLink(null);
|
||||
setSharesLoaded(false);
|
||||
setShares([]);
|
||||
}, [open, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || sharesLoaded) return;
|
||||
setSharesLoaded(true);
|
||||
Promise.all([
|
||||
getDirectory(origin).catch(() => ({ users: [] })),
|
||||
getActiveSessionShares(hostId, origin).catch(() => ({ shares: [] })),
|
||||
]).then(([usersRes, sharesRes]) => {
|
||||
setUsers(
|
||||
(usersRes.users ?? []).map((u) => ({
|
||||
id: String(u.id),
|
||||
username: u.username,
|
||||
})),
|
||||
);
|
||||
setShares(sharesRes.shares ?? []);
|
||||
});
|
||||
}, [open, hostId, origin, sharesLoaded]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? users.filter((u) => u.username.toLowerCase().includes(q))
|
||||
: users;
|
||||
}, [users, search]);
|
||||
|
||||
const expiryHours = (() => {
|
||||
if (expiryPreset === "custom") {
|
||||
const hours = Number(customHours);
|
||||
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
|
||||
}
|
||||
return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
|
||||
})();
|
||||
|
||||
async function refreshShares() {
|
||||
try {
|
||||
const res = await getActiveSessionShares(hostId, origin);
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!sessionId) return;
|
||||
if (mode === "user" && !selectedUserId) return;
|
||||
if (expiryPreset === "custom" && !expiryHours) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const base =
|
||||
mode === "link"
|
||||
? resolvePublicUrl
|
||||
? await resolvePublicUrl(origin)
|
||||
: window.location.href
|
||||
: null;
|
||||
if (mode === "link" && !guestViewUrl(base, "shared", "check")) {
|
||||
toast.error(t("sessionSharing.linkNeedsServer"));
|
||||
return;
|
||||
}
|
||||
const result = await createSessionShare(
|
||||
{
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType: mode,
|
||||
targetUserId:
|
||||
mode === "user" ? (selectedUserId ?? undefined) : undefined,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
},
|
||||
origin,
|
||||
);
|
||||
|
||||
if (mode === "link" && result.linkToken) {
|
||||
setCreatedLink(guestViewUrl(base, "shared", result.linkToken));
|
||||
toast.success(t("sessionSharing.linkCreated"));
|
||||
} else {
|
||||
toast.success(t("sessionSharing.shareCreated"));
|
||||
setSelectedUserId(null);
|
||||
}
|
||||
await refreshShares();
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number })?.status;
|
||||
if (mode === "user" && status === 403) {
|
||||
toast.error(t("sessionSharing.userLacksHostAccess"));
|
||||
} else {
|
||||
toast.error(t("sessionSharing.shareFailed"));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyLink() {
|
||||
if (!createdLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdLink);
|
||||
toast.success(t("sessionSharing.linkCopied"));
|
||||
} catch {
|
||||
// clipboard API unavailable, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(shareId: string) {
|
||||
try {
|
||||
await revokeSessionShare(shareId, origin);
|
||||
setShares((prev) => prev.filter((s) => s.id !== shareId));
|
||||
toast.success(t("sessionSharing.revoked"));
|
||||
} catch {
|
||||
toast.error(t("sessionSharing.revokeFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "link"
|
||||
? t("sessionSharing.linkModeDescription")
|
||||
: t("sessionSharing.userModeDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
{(["link", "user"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
setMode(m);
|
||||
setCreatedLink(null);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m === "link" ? (
|
||||
<Link2 className="size-3 shrink-0" />
|
||||
) : (
|
||||
<User className="size-3 shrink-0" />
|
||||
)}
|
||||
{m === "link"
|
||||
? t("sessionSharing.modeTab.link")
|
||||
: t("sessionSharing.modeTab.user")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "user" && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder={t("sessionSharing.searchUsersPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col border border-border h-28 overflow-y-auto">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("sessionSharing.noUsersFound")}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const isSelected = selectedUserId === user.id;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="size-2.5 text-background" />
|
||||
)}
|
||||
</div>
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{user.username}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("sessionSharing.permissionLevel.label")}
|
||||
</span>
|
||||
<select
|
||||
value={permissionLevel}
|
||||
onChange={(e) =>
|
||||
setPermissionLevel(
|
||||
e.target.value as SessionSharePermissionLevel,
|
||||
)
|
||||
}
|
||||
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<option value="read-only">
|
||||
{t("sessionSharing.permissionLevel.readOnly")}
|
||||
</option>
|
||||
<option value="read-write">
|
||||
{t("sessionSharing.permissionLevel.readWrite")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex flex-col gap-1 shrink-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
|
||||
{t("sessionSharing.expiryLabel")}
|
||||
</span>
|
||||
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
|
||||
{t(`hosts.sharing.expiry.${expiryPreset}`)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{EXPIRY_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.key}
|
||||
onClick={() => setExpiryPreset(preset.key)}
|
||||
>
|
||||
{expiryPreset === preset.key ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.expiry.${preset.key}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{expiryPreset === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
autoFocus
|
||||
placeholder={t("sessionSharing.customHoursPlaceholder")}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{permissionLevel === "read-only"
|
||||
? t("sessionSharing.permissionLevel.readOnlyDescription")
|
||||
: t("sessionSharing.permissionLevel.readWriteDescription")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={
|
||||
!sessionId ||
|
||||
submitting ||
|
||||
(mode === "user" && !selectedUserId) ||
|
||||
(expiryPreset === "custom" && !expiryHours)
|
||||
}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{mode === "link"
|
||||
? t("sessionSharing.createLinkButton")
|
||||
: t("sessionSharing.createShareButton")}
|
||||
</Button>
|
||||
|
||||
{createdLink && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input readOnly value={createdLink} className="text-xs" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={handleCopyLink}
|
||||
title={t("sessionSharing.copyLink")}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0 border-t border-border pt-2">
|
||||
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
{t("sessionSharing.activeShares")}
|
||||
{shares.length > 0 && (
|
||||
<span className="text-muted-foreground/40">
|
||||
({shares.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col max-h-40 overflow-y-auto">
|
||||
{shares.length === 0 && (
|
||||
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("sessionSharing.noActiveShares")}
|
||||
</div>
|
||||
)}
|
||||
{shares.map((share) => {
|
||||
const targetUser = users.find(
|
||||
(u) => u.id === share.targetUserId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{share.shareType === "link" ? (
|
||||
<Link2 className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-semibold">
|
||||
{share.shareType === "link"
|
||||
? t("sessionSharing.linkShareBadge")
|
||||
: t("sessionSharing.userShareBadge", {
|
||||
username:
|
||||
targetUser?.username ??
|
||||
share.targetUserId ??
|
||||
"?",
|
||||
})}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-x-2.5 text-[10px] text-muted-foreground/60">
|
||||
<span>
|
||||
{share.permissionLevel === "read-write"
|
||||
? t("sessionSharing.permissionLevel.readWrite")
|
||||
: t("sessionSharing.permissionLevel.readOnly")}
|
||||
</span>
|
||||
<span>
|
||||
{t("sessionSharing.expiresAt", {
|
||||
date: new Date(share.expiresAt).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
||||
onClick={() => handleRevoke(share.id)}
|
||||
>
|
||||
{t("sessionSharing.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { AlertCircle, Eye, Users } from "lucide-react";
|
||||
import {
|
||||
resolveShareLink,
|
||||
type ResolvedShareLink,
|
||||
type ShareLinkErrorKind,
|
||||
} from "./api";
|
||||
import { Loader, RemoteDisplay, wsUrlForPath } from "./shared";
|
||||
|
||||
const PING_INTERVAL_MS = 30000;
|
||||
|
||||
interface TerminalWsMessage {
|
||||
type: string;
|
||||
data?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// A shared session link always resolves against the desktop app's embedded
|
||||
// local backend: joining a session on someone else's remote server is not
|
||||
// supported from the desktop app.
|
||||
async function resolveTerminalWsUrl(wsPath: string): Promise<string> {
|
||||
const url = await wsUrlForPath(wsPath);
|
||||
if (!url) throw new Error("No terminal endpoint is available");
|
||||
return url;
|
||||
}
|
||||
|
||||
export interface SessionParticipantInfo {
|
||||
isOwner: boolean;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export function ParticipantsBadge({
|
||||
participants,
|
||||
ownerLabel,
|
||||
}: {
|
||||
participants: SessionParticipantInfo[];
|
||||
ownerLabel: string;
|
||||
}) {
|
||||
if (participants.length < 2) return null;
|
||||
const names = participants
|
||||
.map((participant) =>
|
||||
participant.isOwner ? ownerLabel : (participant.label ?? "?"),
|
||||
)
|
||||
.join(", ");
|
||||
return (
|
||||
<div
|
||||
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
title={names}
|
||||
>
|
||||
<Users className="size-3.5" />
|
||||
{participants.length}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenteredMessage({
|
||||
icon,
|
||||
message,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4 w-full"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
{icon}
|
||||
<p
|
||||
className="text-sm font-semibold text-center max-w-xs"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuestTerminalView({
|
||||
share,
|
||||
wsPath,
|
||||
hideBadges = false,
|
||||
onParticipantsChange,
|
||||
}: {
|
||||
share: Pick<ResolvedShareLink, "permissionLevel">;
|
||||
/** The socket path the backend handed out, query string included. */
|
||||
wsPath: string;
|
||||
/** Suppress the built-in overlay badges when the host page renders its own. */
|
||||
hideBadges?: boolean;
|
||||
onParticipantsChange?: (participants: SessionParticipantInfo[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const [ended, setEnded] = useState<string | null>(null);
|
||||
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
|
||||
[],
|
||||
);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const onParticipantsChangeRef = useRef(onParticipantsChange);
|
||||
onParticipantsChangeRef.current = onParticipantsChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
terminal.options.theme = { background: "#0c0d0b" };
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(xtermRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
let sharedSize: { cols: number; rows: number } | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (sharedSize) terminal.resize(sharedSize.cols, sharedSize.rows);
|
||||
else fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
let cancelled = false;
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
resolveTerminalWsUrl(wsPath).then((url) => {
|
||||
if (cancelled) return;
|
||||
ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: TerminalWsMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "resized": {
|
||||
const { cols, rows } = msg;
|
||||
if (
|
||||
typeof cols === "number" &&
|
||||
typeof rows === "number" &&
|
||||
Number.isInteger(cols) &&
|
||||
Number.isInteger(rows) &&
|
||||
cols > 0 &&
|
||||
rows > 0
|
||||
) {
|
||||
sharedSize = { cols, rows };
|
||||
terminal.resize(cols, rows);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "data":
|
||||
if (typeof msg.data === "string") terminal.write(msg.data);
|
||||
break;
|
||||
case "participants":
|
||||
if (Array.isArray(msg.participants)) {
|
||||
const next = msg.participants as SessionParticipantInfo[];
|
||||
setParticipants(next);
|
||||
onParticipantsChangeRef.current?.(next);
|
||||
}
|
||||
break;
|
||||
case "sessionExpired":
|
||||
case "sessionTerminatedByOwner":
|
||||
case "session_ended":
|
||||
setEnded(t("sessionSharing.guestView.sessionEnded"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
|
||||
};
|
||||
|
||||
if (share.permissionLevel === "read-write") {
|
||||
terminal.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver.disconnect();
|
||||
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
|
||||
ws?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, wsPath]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{!hideBadges && (
|
||||
<>
|
||||
<ParticipantsBadge
|
||||
participants={participants}
|
||||
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
|
||||
/>
|
||||
{share.permissionLevel === "read-only" && (
|
||||
<ReadOnlyBadge
|
||||
label={t("sessionSharing.guestView.readOnlyBadge")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{ended && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={ended}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={xtermRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
|
||||
const { t } = useTranslation();
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
if (!share.connectParams?.token) {
|
||||
return (
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={t("sessionSharing.guestView.linkInvalid")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{share.permissionLevel === "read-only" && (
|
||||
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
||||
)}
|
||||
{connectionError && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={connectionError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<RemoteDisplay
|
||||
token={share.connectParams.token}
|
||||
protocol={share.protocol}
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SharedSessionView() {
|
||||
const { t } = useTranslation();
|
||||
const [share, setShare] = useState<ResolvedShareLink | null>(null);
|
||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
setError(t("sessionSharing.guestView.linkInvalid"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLinkToken(token);
|
||||
|
||||
resolveShareLink(token)
|
||||
.then((resolved) => setShare(resolved))
|
||||
.catch((err) => {
|
||||
const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
|
||||
if (kind === "rate-limited") {
|
||||
setError(t("sessionSharing.guestView.rateLimited"));
|
||||
} else {
|
||||
setError(t("sessionSharing.guestView.linkInvalid"));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{loading && <Loader message={t("sessionSharing.guestView.loading")} />}
|
||||
{!loading && error && (
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={error}
|
||||
/>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
share &&
|
||||
linkToken &&
|
||||
(share.protocol === "ssh" ? (
|
||||
<GuestTerminalView share={share} wsPath={share.wsPath} />
|
||||
) : (
|
||||
<GuestGuacamoleView share={share} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { SquareTerminal } from "lucide-react";
|
||||
import {
|
||||
invokeAction,
|
||||
useHosts,
|
||||
usePermission,
|
||||
useTranslation,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Badge, Button } from "@termix/plugin-sdk/ui";
|
||||
import { getSharedWithMe } from "./api";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
interface SharedSession {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
function isSharedSession(value: unknown): value is SharedSession {
|
||||
const row = value as Partial<SharedSession> | null;
|
||||
return (
|
||||
!!row &&
|
||||
typeof row.sessionId === "string" &&
|
||||
typeof row.hostName === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active connections list's "Shared with me" section: live sessions other
|
||||
* users shared with this one, each joinable in a terminal tab.
|
||||
*/
|
||||
export function SharedWithMeSection({
|
||||
search = "",
|
||||
openTabData = [],
|
||||
}: {
|
||||
search?: string;
|
||||
openTabData?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const allowed = usePermission("use");
|
||||
const [sessions, setSessions] = useState<SharedSession[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!allowed) return;
|
||||
getSharedWithMe()
|
||||
.then((rows) => setSessions(rows.filter(isSharedSession)))
|
||||
.catch(() => setSessions([]));
|
||||
}, [allowed]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const joined = new Set(
|
||||
openTabData
|
||||
.map((data) => data.joinSharedSessionId)
|
||||
.filter((id): id is string => typeof id === "string"),
|
||||
);
|
||||
const q = search.trim().toLowerCase();
|
||||
const visible = sessions.filter(
|
||||
(session) =>
|
||||
!joined.has(session.sessionId) &&
|
||||
(!q ||
|
||||
session.hostName.toLowerCase().includes(q) ||
|
||||
(session.sharedByUsername ?? "").toLowerCase().includes(q)),
|
||||
);
|
||||
if (!allowed || visible.length === 0) return null;
|
||||
|
||||
const join = (session: SharedSession) => {
|
||||
if (!session.shareId) return;
|
||||
const host: PluginHostRecord = hosts.find(
|
||||
(candidate) => String(candidate.id) === String(session.hostId),
|
||||
) ?? {
|
||||
id: String(session.hostId),
|
||||
name: session.hostName,
|
||||
ip: "",
|
||||
port: 0,
|
||||
username: "",
|
||||
};
|
||||
void invokeAction("terminal.open", host, {
|
||||
joinSharedSessionId: session.sessionId,
|
||||
joinShareId: session.shareId,
|
||||
label: t("connections.sharedSessionLabel", {
|
||||
hostName: session.hostName,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||
{t("connections.sectionSharedWithMe")}
|
||||
</span>
|
||||
<span className="rounded bg-muted/60 px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground/40">
|
||||
{visible.length}
|
||||
</span>
|
||||
</div>
|
||||
{visible.map((session) => {
|
||||
const readWrite = session.permissionLevel === "read-write";
|
||||
return (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="group flex items-center gap-2.5 border-b border-border/40 px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded bg-muted/60 text-muted-foreground">
|
||||
<SquareTerminal className="size-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
session.isConnected
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1 truncate text-xs font-semibold text-foreground">
|
||||
{session.hostName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`h-4 shrink-0 border-border/60 px-1 py-0 font-mono text-[9px] ${
|
||||
readWrite ? "text-accent-brand" : "text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{readWrite
|
||||
? t("sessionSharing.permissionLevel.readWrite")
|
||||
: t("sessionSharing.permissionLevel.readOnly")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="truncate pl-3 text-[10px] text-muted-foreground/60">
|
||||
{t("connections.sharedBy", {
|
||||
username: session.sharedByUsername ?? "?",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-2 text-[10px] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => join(session)}
|
||||
>
|
||||
{t("connections.join")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* The plugin's own /plugin-api/session-sharing client, bound in activate.
|
||||
* Guest pages use it too: the two guest routes are public.
|
||||
*/
|
||||
let client: PluginApiClient | null = null;
|
||||
let clientFor: ((origin?: unknown) => PluginApiClient) | null = null;
|
||||
|
||||
export function bindApi(
|
||||
api: PluginApiClient | null,
|
||||
apiFor?: (origin?: unknown) => PluginApiClient,
|
||||
): void {
|
||||
client = api;
|
||||
clientFor = api ? (apiFor ?? null) : null;
|
||||
}
|
||||
|
||||
/** The session lives on the backend its tab connected through. */
|
||||
function api(origin?: SessionOrigin): PluginApiClient {
|
||||
if (!client) throw new Error("Session sharing is not running");
|
||||
return origin && clientFor ? clientFor(origin) : client;
|
||||
}
|
||||
|
||||
export type SessionOrigin = "local" | "remote";
|
||||
|
||||
interface HttpError {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
}
|
||||
|
||||
function statusOf(error: unknown): number | undefined {
|
||||
return (error as HttpError)?.response?.status;
|
||||
}
|
||||
|
||||
/** The server's own message, like core's handleApiError. */
|
||||
function apiError(error: unknown, fallback: string): Error {
|
||||
const message = (error as HttpError)?.response?.data?.error;
|
||||
return new Error(message || fallback);
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
request: () => Promise<{ data: T }>,
|
||||
fallback: string,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guest share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ResolvedShareLink {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
permissionLevel: "read-only" | "read-write";
|
||||
wsPath: string;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
||||
|
||||
export class ShareLinkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind: ShareLinkErrorKind,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ShareLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveShareLink(
|
||||
linkToken: string,
|
||||
): Promise<ResolvedShareLink> {
|
||||
try {
|
||||
const response = await api().get<ResolvedShareLink>(
|
||||
`/resolve/${encodeURIComponent(linkToken)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const status = statusOf(error);
|
||||
if (status === 404) {
|
||||
throw new ShareLinkError(
|
||||
"Share link is invalid, expired, or revoked",
|
||||
"not-found",
|
||||
);
|
||||
}
|
||||
if (status === 429) {
|
||||
throw new ShareLinkError(
|
||||
"Too many attempts, please try again shortly",
|
||||
"rate-limited",
|
||||
);
|
||||
}
|
||||
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Owner-side shares
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
export type SessionShareType = "link" | "user";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareRecord {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
sessionId: string;
|
||||
tabInstanceId: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId: string | null;
|
||||
linkToken: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastJoinedAt: string | null;
|
||||
joinCount: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareRequest {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string;
|
||||
protocol: SessionShareProtocol;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiryHours?: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareResponse {
|
||||
shareId: string;
|
||||
linkToken: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function createSessionShare(
|
||||
request: CreateSessionShareRequest,
|
||||
origin?: SessionOrigin,
|
||||
): Promise<CreateSessionShareResponse> {
|
||||
return call(
|
||||
() => api(origin).post<CreateSessionShareResponse>("/create", request),
|
||||
"Failed to create session share",
|
||||
);
|
||||
}
|
||||
|
||||
export function getActiveSessionShares(
|
||||
hostId: number,
|
||||
origin?: SessionOrigin,
|
||||
): Promise<{ shares: SessionShareRecord[] }> {
|
||||
return call(
|
||||
() =>
|
||||
api(origin).get<{ shares: SessionShareRecord[] }>(
|
||||
`/host/${hostId}/active`,
|
||||
),
|
||||
"Failed to fetch active session shares",
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeSessionShare(
|
||||
shareId: string,
|
||||
origin?: SessionOrigin,
|
||||
): Promise<unknown> {
|
||||
return call(
|
||||
() => api(origin).delete(`/${encodeURIComponent(shareId)}`),
|
||||
"Failed to revoke session share",
|
||||
);
|
||||
}
|
||||
|
||||
export interface DirectoryUser {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DirectoryRole {
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/** Users and roles to share with or invite. */
|
||||
export function getDirectory(origin?: SessionOrigin): Promise<{
|
||||
users: DirectoryUser[];
|
||||
roles: DirectoryRole[];
|
||||
}> {
|
||||
return call(
|
||||
() =>
|
||||
api(origin).get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
|
||||
"/directory",
|
||||
),
|
||||
"Failed to list users and roles",
|
||||
);
|
||||
}
|
||||
|
||||
/** Live sessions other users shared with the caller, as active-session rows. */
|
||||
export function getSharedWithMe(): Promise<unknown[]> {
|
||||
return call(
|
||||
() => api().get<unknown[]>("/shared-with-me"),
|
||||
"Failed to list shared sessions",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collab rooms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CollabRoom {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkEnabled: boolean;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CollabRoomMember {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CollabOnlineUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface CollabStage {
|
||||
presenterUserId: string | null;
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet" | null;
|
||||
hostId: number | null;
|
||||
shareId: string | null;
|
||||
sessionId?: string;
|
||||
controllerUserId?: string | null;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
export interface CollabControlRequest {
|
||||
userId: string;
|
||||
username: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export interface CollabRoomDetail {
|
||||
room: CollabRoom;
|
||||
me: string;
|
||||
isHost: boolean;
|
||||
members: CollabRoomMember[];
|
||||
online: CollabOnlineUser[];
|
||||
stage: CollabStage;
|
||||
/** Anonymous guests watching the stage through the guest link. */
|
||||
guests?: { label: string | null }[];
|
||||
controllerUserId: string | null;
|
||||
controlRequests: CollabControlRequest[];
|
||||
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
|
||||
eventsWsPath: string;
|
||||
}
|
||||
|
||||
export interface CollabGuestStage {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
shareId: string;
|
||||
wsPath?: string;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
/** Anonymous: guests poll this to follow the presenter. Throws on 404/429. */
|
||||
export async function resolveCollabGuestStage(
|
||||
token: string,
|
||||
): Promise<{ roomName: string; stage: CollabGuestStage | null }> {
|
||||
const response = await api().get<{
|
||||
roomName: string;
|
||||
stage: CollabGuestStage | null;
|
||||
}>(`/guest/${encodeURIComponent(token)}`);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Presentation, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useCurrentUser,
|
||||
usePermission,
|
||||
type PanelProps,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { bindApi } from "./api";
|
||||
import { SharedWithMeSection } from "./SharedWithMeSection";
|
||||
import CollabGuestView from "./CollabGuestView";
|
||||
import { CollabPanel } from "./CollabPanel";
|
||||
import { CollabRoomTab } from "./CollabRoomTab";
|
||||
import SharedSessionView from "./SharedSessionView";
|
||||
import { ParticipantsOverlay } from "./ParticipantsOverlay";
|
||||
import {
|
||||
ShareDialogHost,
|
||||
bindShareApp,
|
||||
closeShareDialog,
|
||||
openShareDialog,
|
||||
type ShareTarget,
|
||||
} from "./share-dialog";
|
||||
import { REMOTE_DISPLAY_SLOT } from "./shared";
|
||||
|
||||
import { connectMeetings, type MeetingBackend } from "./meeting-backend";
|
||||
|
||||
let meetingApp: TermixApp;
|
||||
|
||||
function MeetingScope({
|
||||
children,
|
||||
serverKey,
|
||||
pin = false,
|
||||
}: {
|
||||
children: (backend: MeetingBackend) => ReactNode;
|
||||
serverKey?: string;
|
||||
pin?: boolean;
|
||||
}) {
|
||||
const [backend, setBackend] = useState<MeetingBackend | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pinned = useRef(serverKey);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let sequence = 0;
|
||||
const refresh = async () => {
|
||||
const request = ++sequence;
|
||||
try {
|
||||
const next = await connectMeetings(meetingApp);
|
||||
if (cancelled || request !== sequence) return;
|
||||
if (
|
||||
pin &&
|
||||
pinned.current !== undefined &&
|
||||
pinned.current !== next.key
|
||||
) {
|
||||
setBackend(null);
|
||||
setError(meetingApp.t("collab.serverChanged"));
|
||||
return;
|
||||
}
|
||||
if (pin) pinned.current ??= next.key;
|
||||
setError(null);
|
||||
setBackend((previous) =>
|
||||
previous?.key === next.key ? previous : next,
|
||||
);
|
||||
} catch (failure) {
|
||||
if (!cancelled) {
|
||||
setBackend(null);
|
||||
setError(String(failure));
|
||||
}
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const dispose = meetingApp.desktop.onRemoteServerChange(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
dispose();
|
||||
};
|
||||
}, [serverKey, pin]);
|
||||
if (error)
|
||||
return (
|
||||
<p role="alert" className="p-3 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
return backend ? (
|
||||
<div key={backend.key} className="contents">
|
||||
{children(backend)}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const COLLAB = "collab";
|
||||
const SHARE_ACTION = "session-sharing.share";
|
||||
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
||||
const INVITE_POLL_MS = 60_000;
|
||||
|
||||
/** `?view=shared` and `?view=collab-guest`, the two anonymous guest links. */
|
||||
function GuestView({ view }: StandaloneViewProps) {
|
||||
return view === "shared" ? <SharedSessionView /> : <CollabGuestView />;
|
||||
}
|
||||
|
||||
function CollabRoomTabView({ tab, isVisible }: TabProps) {
|
||||
const roomId = tab.data?.roomId;
|
||||
const serverKey =
|
||||
typeof tab.data?.meetingServerKey === "string"
|
||||
? tab.data.meetingServerKey
|
||||
: undefined;
|
||||
return (
|
||||
<MeetingScope serverKey={serverKey} pin>
|
||||
{(backend) => (
|
||||
<CollabRoomTab
|
||||
roomId={typeof roomId === "string" ? roomId : undefined}
|
||||
isVisible={isVisible}
|
||||
backend={backend}
|
||||
/>
|
||||
)}
|
||||
</MeetingScope>
|
||||
);
|
||||
}
|
||||
|
||||
function CollabPanelView({ shell }: PanelProps) {
|
||||
return (
|
||||
<MeetingScope>
|
||||
{(backend) => (
|
||||
<CollabPanel
|
||||
backend={backend}
|
||||
onOpenRoom={(room) =>
|
||||
shell.openTab(null, COLLAB, {
|
||||
label: room.name,
|
||||
forceNewTab: true,
|
||||
data: { roomId: room.id, meetingServerKey: backend.key },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MeetingScope>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms are discovered by polling, so a room this browser has never shown
|
||||
* gets one toast with an Open action. The first poll after login only
|
||||
* records what already exists.
|
||||
*/
|
||||
function createInviteWatcher(app: TermixApp): ComponentType {
|
||||
return function InviteWatcher() {
|
||||
const user = useCurrentUser();
|
||||
const allowed = usePermission("use");
|
||||
const userId = user?.userId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !allowed) return;
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
try {
|
||||
const { rooms = [] } = await (
|
||||
await connectMeetings(app)
|
||||
).api.listCollabRooms();
|
||||
if (cancelled) return;
|
||||
let seen: string[] = [];
|
||||
try {
|
||||
seen = JSON.parse(localStorage.getItem(SEEN_ROOMS_KEY) ?? "[]");
|
||||
} catch {
|
||||
seen = [];
|
||||
}
|
||||
const seenSet = new Set(seen);
|
||||
const fresh = rooms.filter((room) => !seenSet.has(room.id));
|
||||
if (fresh.length === 0) return;
|
||||
localStorage.setItem(
|
||||
SEEN_ROOMS_KEY,
|
||||
JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]),
|
||||
);
|
||||
if (seen.length === 0) return;
|
||||
for (const room of fresh) {
|
||||
if (room.ownerUserId === userId) continue;
|
||||
toast(app.t("collab.invitedTo", { name: room.name }), {
|
||||
action: {
|
||||
label: app.t("collab.openRoom"),
|
||||
onClick: () => app.tabs.openRailView(COLLAB),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* next poll */
|
||||
}
|
||||
};
|
||||
void check();
|
||||
const timer = setInterval(() => void check(), INVITE_POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [userId, allowed]);
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** A terminal toolbar action is invoked with the terminal's slot API. */
|
||||
interface TerminalSlotApi {
|
||||
getShareTarget?: () => ShareTarget | null;
|
||||
}
|
||||
|
||||
/** A remote desktop toolbar action is invoked with the session it shows. */
|
||||
interface RemoteDesktopToolbarContext {
|
||||
hostId: number;
|
||||
sessionId: string | null;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId?: string;
|
||||
origin?: "local" | "remote";
|
||||
}
|
||||
|
||||
function isRemoteDesktopContext(
|
||||
value: unknown,
|
||||
): value is RemoteDesktopToolbarContext {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
"protocol" in value &&
|
||||
"hostId" in value
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
meetingApp = app;
|
||||
bindApi(app.api, app.apiFor);
|
||||
bindShareApp(app);
|
||||
app.onDispose(() => {
|
||||
closeShareDialog();
|
||||
bindApi(null);
|
||||
bindShareApp(null);
|
||||
});
|
||||
|
||||
// Rooms and share links draw remote desktop streams through this slot.
|
||||
app.declareActionSlot({ id: REMOTE_DISPLAY_SLOT, accepts: ["component"] });
|
||||
|
||||
// Guest pages open the tab type's standalone view by ?view= name.
|
||||
const guestViews = {
|
||||
standalone: GuestView,
|
||||
standaloneViews: ["shared", "collab-guest"],
|
||||
};
|
||||
|
||||
if (app.guest) {
|
||||
app.registerTab(COLLAB, () => null, { hostless: true, ...guestViews });
|
||||
return;
|
||||
}
|
||||
|
||||
app.registerRailItem({
|
||||
id: COLLAB,
|
||||
icon: Presentation,
|
||||
titleKey: "nav.collab",
|
||||
kind: "panel",
|
||||
after: "connections",
|
||||
separatorAfter: true,
|
||||
permission: "use",
|
||||
});
|
||||
app.registerPanel(COLLAB, CollabPanelView);
|
||||
app.registerTab(COLLAB, CollabRoomTabView, {
|
||||
icon: Presentation,
|
||||
titleKey: "nav.collab",
|
||||
hostless: true,
|
||||
multiInstance: true,
|
||||
...guestViews,
|
||||
});
|
||||
|
||||
app.registerAction(
|
||||
SHARE_ACTION,
|
||||
(context: unknown) => {
|
||||
let target: ShareTarget | null = null;
|
||||
if (isRemoteDesktopContext(context)) {
|
||||
target = context.sessionId
|
||||
? {
|
||||
hostId: context.hostId,
|
||||
sessionId: context.sessionId,
|
||||
protocol: context.protocol,
|
||||
tabInstanceId: context.tabInstanceId,
|
||||
origin: context.origin,
|
||||
}
|
||||
: null;
|
||||
} else {
|
||||
target = (context as TerminalSlotApi)?.getShareTarget?.() ?? null;
|
||||
}
|
||||
if (target) openShareDialog(target);
|
||||
else toast.error(app.t("sessionSharing.notReadyToShare"));
|
||||
},
|
||||
{ permission: "use" },
|
||||
);
|
||||
for (const slot of ["terminal.toolbar", "remote-desktop.toolbar"]) {
|
||||
app.registerSlotContribution(slot, {
|
||||
actionId: SHARE_ACTION,
|
||||
titleKey: "sessionSharing.shareButton",
|
||||
icon: Share2,
|
||||
kind: "button",
|
||||
});
|
||||
}
|
||||
|
||||
// The tab bar's right-click menu, for a terminal whose toolbar is hidden.
|
||||
app.registerSlotContribution("tab.menu", {
|
||||
actionId: SHARE_ACTION,
|
||||
titleKey: "sessionSharing.shareButton",
|
||||
icon: Share2,
|
||||
kind: "button",
|
||||
when: (context) => {
|
||||
const handle = context.handle as
|
||||
{ getShareTarget?: () => unknown } | null | undefined;
|
||||
return typeof handle?.getShareTarget === "function"
|
||||
? handle.getShareTarget() !== null
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
// A small share button on the tab itself, as in 2.8.
|
||||
app.registerSlotContribution("tab.inline", {
|
||||
actionId: SHARE_ACTION,
|
||||
titleKey: "sessionSharing.shareButton",
|
||||
icon: Share2,
|
||||
kind: "button",
|
||||
when: (context) =>
|
||||
typeof (context.handle as { getShareTarget?: unknown } | null | undefined)
|
||||
?.getShareTarget === "function",
|
||||
});
|
||||
|
||||
app.registerSlotContribution("terminal.overlay", {
|
||||
actionId: "session-sharing.participants",
|
||||
titleKey: "collab.members",
|
||||
kind: "component",
|
||||
component: ParticipantsOverlay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
|
||||
app.registerSlotContribution("shell.overlay", {
|
||||
actionId: "session-sharing.shareDialog",
|
||||
titleKey: "sessionSharing.shareButton",
|
||||
kind: "component",
|
||||
component: ShareDialogHost as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
app.registerSlotContribution("shell.overlay", {
|
||||
actionId: "session-sharing.inviteWatcher",
|
||||
titleKey: "nav.collab",
|
||||
kind: "component",
|
||||
component: createInviteWatcher(app) as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
|
||||
// The "Shared with me" section of the active connections list.
|
||||
app.registerSlotContribution("connections.sections", {
|
||||
actionId: "session-sharing.sharedWithMe",
|
||||
titleKey: "connections.sectionSharedWithMe",
|
||||
kind: "component",
|
||||
component: SharedWithMeSection as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
closeShareDialog();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
CollabRoom,
|
||||
CollabRoomDetail,
|
||||
CollabStage,
|
||||
CollabControlRequest,
|
||||
DirectoryUser,
|
||||
DirectoryRole,
|
||||
} from "./api";
|
||||
|
||||
export function createMeetingApi(
|
||||
client: PluginApiClient,
|
||||
assertCurrent: () => Promise<void>,
|
||||
) {
|
||||
const api = () => client;
|
||||
async function call<T>(
|
||||
request: () => Promise<{ data: T }>,
|
||||
fallback: string,
|
||||
): Promise<T> {
|
||||
await assertCurrent();
|
||||
try {
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
const response = error as { response?: { data?: { error?: string } } };
|
||||
throw new Error(response.response?.data?.error || fallback);
|
||||
}
|
||||
}
|
||||
const room = (roomId: string, suffix = "") =>
|
||||
`/rooms/${encodeURIComponent(roomId)}${suffix}`;
|
||||
|
||||
function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
|
||||
return call(
|
||||
() => api().get<{ rooms: CollabRoom[] }>("/rooms"),
|
||||
"Failed to list rooms",
|
||||
);
|
||||
}
|
||||
|
||||
function createCollabRoom(
|
||||
name: string,
|
||||
persistent: boolean,
|
||||
): Promise<{ room: CollabRoom }> {
|
||||
return call(
|
||||
() => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }),
|
||||
"Failed to create room",
|
||||
);
|
||||
}
|
||||
|
||||
function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
|
||||
return call(
|
||||
() => api().get<CollabRoomDetail>(room(roomId)),
|
||||
"Failed to get room",
|
||||
);
|
||||
}
|
||||
|
||||
async function inviteCollabMembers(
|
||||
roomId: string,
|
||||
targets: { userIds?: string[]; roleIds?: number[] },
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/members"), targets),
|
||||
"Failed to invite members",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeCollabMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() =>
|
||||
api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)),
|
||||
"Failed to remove member",
|
||||
);
|
||||
}
|
||||
|
||||
function presentCollabStage(
|
||||
roomId: string,
|
||||
input: { protocol: string; sessionId: string; hostId: number },
|
||||
): Promise<{ stage: CollabStage }> {
|
||||
return call(
|
||||
() => api().post<{ stage: CollabStage }>(room(roomId, "/present"), input),
|
||||
"Failed to start presenting",
|
||||
);
|
||||
}
|
||||
|
||||
async function stopCollabStage(roomId: string): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/stop")),
|
||||
"Failed to stop presenting",
|
||||
);
|
||||
}
|
||||
|
||||
function getCollabStage(
|
||||
roomId: string,
|
||||
): Promise<{ stage: CollabStage | null }> {
|
||||
return call(
|
||||
() => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")),
|
||||
"Failed to resolve stage",
|
||||
);
|
||||
}
|
||||
|
||||
async function setCollabStageControl(
|
||||
roomId: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/control"), { userId }),
|
||||
"Failed to change stage control",
|
||||
);
|
||||
}
|
||||
|
||||
function requestCollabStageControl(
|
||||
roomId: string,
|
||||
): Promise<{ request: CollabControlRequest }> {
|
||||
return call(
|
||||
() =>
|
||||
api().post<{ request: CollabControlRequest }>(
|
||||
room(roomId, "/control/request"),
|
||||
),
|
||||
"Failed to request control",
|
||||
);
|
||||
}
|
||||
|
||||
function listCollabControlRequests(
|
||||
roomId: string,
|
||||
): Promise<{ requests: CollabControlRequest[] }> {
|
||||
return call(
|
||||
() =>
|
||||
api().get<{ requests: CollabControlRequest[] }>(
|
||||
room(roomId, "/control/requests"),
|
||||
),
|
||||
"Failed to list control requests",
|
||||
);
|
||||
}
|
||||
|
||||
async function dismissCollabControlRequest(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() =>
|
||||
api().delete(
|
||||
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
|
||||
),
|
||||
"Failed to dismiss control request",
|
||||
);
|
||||
}
|
||||
|
||||
async function endCollabRoom(roomId: string): Promise<void> {
|
||||
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
|
||||
}
|
||||
|
||||
async function deleteCollabRoom(roomId: string): Promise<void> {
|
||||
await call(() => api().delete(room(roomId)), "Failed to delete room");
|
||||
}
|
||||
|
||||
function setCollabGuestLink(
|
||||
roomId: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ guestLinkToken: string | null }> {
|
||||
return call(
|
||||
() =>
|
||||
api().post<{ guestLinkToken: string | null }>(
|
||||
room(roomId, "/guest-link"),
|
||||
{ enabled },
|
||||
),
|
||||
"Failed to update guest link",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeCollabGuests(roomId: string): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/guests/remove")),
|
||||
"Failed to remove guests",
|
||||
);
|
||||
}
|
||||
|
||||
function getDirectory() {
|
||||
return call(
|
||||
() =>
|
||||
client.get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
|
||||
"/directory",
|
||||
),
|
||||
"Failed to list users and roles",
|
||||
);
|
||||
}
|
||||
function resolveHost(syncId: string) {
|
||||
return call(
|
||||
() =>
|
||||
client.get<{ id: number }>(
|
||||
`/meeting-host/${encodeURIComponent(syncId)}`,
|
||||
),
|
||||
"The host must be synced to the meeting server before presenting",
|
||||
);
|
||||
}
|
||||
return {
|
||||
listCollabRooms,
|
||||
createCollabRoom,
|
||||
getCollabRoom,
|
||||
inviteCollabMembers,
|
||||
removeCollabMember,
|
||||
presentCollabStage,
|
||||
stopCollabStage,
|
||||
getCollabStage,
|
||||
setCollabStageControl,
|
||||
requestCollabStageControl,
|
||||
listCollabControlRequests,
|
||||
dismissCollabControlRequest,
|
||||
endCollabRoom,
|
||||
deleteCollabRoom,
|
||||
setCollabGuestLink,
|
||||
removeCollabGuests,
|
||||
getDirectory,
|
||||
resolveHost,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { createMeetingApi } from "./meeting-api";
|
||||
|
||||
export type MeetingBackend = Awaited<ReturnType<typeof connectMeetings>>;
|
||||
|
||||
/** A room, its hosts and its sockets must all belong to the same backend. */
|
||||
export async function connectMeetings(app: TermixApp) {
|
||||
const remoteUrl = app.desktop.available
|
||||
? await app.desktop.remoteServerUrl()
|
||||
: null;
|
||||
const key = remoteUrl?.replace(/\/+$/, "") || "local";
|
||||
const origin = remoteUrl ? ("remote" as const) : ("local" as const);
|
||||
const publicUrl =
|
||||
remoteUrl || (!app.desktop.available ? window.location.href : null);
|
||||
const assertCurrent = async () => {
|
||||
const current = app.desktop.available
|
||||
? await app.desktop.remoteServerUrl()
|
||||
: null;
|
||||
if ((current?.replace(/\/+$/, "") || "local") !== key) {
|
||||
throw new Error(app.t("collab.serverChanged"));
|
||||
}
|
||||
};
|
||||
return {
|
||||
key,
|
||||
origin,
|
||||
publicUrl,
|
||||
assertCurrent,
|
||||
api: createMeetingApi(app.apiFor(origin), assertCurrent),
|
||||
};
|
||||
}
|
||||
|
||||
export function meetingGuestUrl(
|
||||
publicUrl: string | null,
|
||||
token: string,
|
||||
): string | null {
|
||||
return guestViewUrl(publicUrl, "collab-guest", token);
|
||||
}
|
||||
|
||||
/** A guest link on a server others can open, never a file:// desktop page. */
|
||||
export function guestViewUrl(
|
||||
publicUrl: string | null,
|
||||
view: string,
|
||||
token: string,
|
||||
): string | null {
|
||||
if (!publicUrl) return null;
|
||||
const url = new URL(publicUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
url.searchParams.set("view", view);
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Where guests reach a session that runs on the given backend. */
|
||||
export async function sessionPublicUrl(
|
||||
app: TermixApp,
|
||||
origin: "local" | "remote" | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!app.desktop.available) return window.location.href;
|
||||
if (origin !== "remote") return null;
|
||||
return (await app.desktop.remoteServerUrl()) || null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { ShareSessionModal } from "./ShareSessionModal";
|
||||
import type { SessionShareProtocol } from "./api";
|
||||
import { sessionPublicUrl } from "./meeting-backend";
|
||||
|
||||
let shareApp: TermixApp | null = null;
|
||||
|
||||
export function bindShareApp(app: TermixApp | null): void {
|
||||
shareApp = app;
|
||||
}
|
||||
|
||||
function resolveShareBase(
|
||||
origin: "local" | "remote" | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!shareApp) return Promise.resolve(window.location.href);
|
||||
return sessionPublicUrl(shareApp, origin);
|
||||
}
|
||||
|
||||
/** What the share dialog needs to share one live session. */
|
||||
export interface ShareTarget {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
tabInstanceId?: string;
|
||||
/** The backend the session runs on; a desktop can be on either. */
|
||||
origin?: "local" | "remote";
|
||||
}
|
||||
|
||||
let current: ShareTarget | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Opens the share dialog, which the shell overlay slot keeps mounted. */
|
||||
export function openShareDialog(target: ShareTarget): void {
|
||||
current = target;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function closeShareDialog(): void {
|
||||
current = null;
|
||||
emit();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Rendered once in "shell.overlay"; shows the dialog when a toolbar asks. */
|
||||
export function ShareDialogHost() {
|
||||
const target = useSyncExternalStore(subscribe, () => current);
|
||||
if (!target) return null;
|
||||
return (
|
||||
<ShareSessionModal
|
||||
open
|
||||
onClose={closeShareDialog}
|
||||
hostId={target.hostId}
|
||||
sessionId={target.sessionId}
|
||||
protocol={target.protocol}
|
||||
tabInstanceId={target.tabInstanceId}
|
||||
origin={target.origin}
|
||||
resolvePublicUrl={resolveShareBase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
invokeAction,
|
||||
useSlotContributions,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { pluginWsUrl } from "@termix/plugin-sdk/ui";
|
||||
|
||||
/**
|
||||
* A remote desktop stream drawn by whichever plugin provides one. Rooms and
|
||||
* share links show RDP, VNC and Telnet without knowing how they are drawn:
|
||||
* the provider contributes a component to this slot and answers the token
|
||||
* action below.
|
||||
*/
|
||||
export const REMOTE_DISPLAY_SLOT = "session.remoteDisplay";
|
||||
export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
|
||||
|
||||
export interface RemoteDisplayProps {
|
||||
token: string;
|
||||
connectionOrigin?: "local" | "remote";
|
||||
protocol: string;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function RemoteDisplay(props: RemoteDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
const contributions = useSlotContributions(REMOTE_DISPLAY_SLOT);
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
contributions.find(
|
||||
(contribution) =>
|
||||
contribution.kind === "component" && contribution.component,
|
||||
),
|
||||
[contributions],
|
||||
);
|
||||
if (!provider?.component) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("sessionSharing.remoteDisplayUnavailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Display = provider.component;
|
||||
return <Display {...(props as unknown as Record<string, unknown>)} />;
|
||||
}
|
||||
|
||||
export interface RemoteSessionToken {
|
||||
token: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
/** Mints a session token for presenting a host, through the provider. */
|
||||
export async function createRemoteSessionToken(
|
||||
hostId: number,
|
||||
origin: unknown,
|
||||
protocol: string,
|
||||
): Promise<RemoteSessionToken | null> {
|
||||
return ((await invokeAction(
|
||||
REMOTE_SESSION_TOKEN_ACTION,
|
||||
hostId,
|
||||
origin,
|
||||
protocol,
|
||||
)) ?? null) as RemoteSessionToken | null;
|
||||
}
|
||||
|
||||
/** A socket URL for a /plugin-ws/<id>/<path>?query path the server handed out. */
|
||||
export async function wsTargetForPath(
|
||||
wsPath: string,
|
||||
origin: "local" | "remote" = "local",
|
||||
) {
|
||||
const [pathname, query] = wsPath.split("?", 2);
|
||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const target = await pluginWsUrl(match[1], match[2], { origin });
|
||||
if (!target) return null;
|
||||
return {
|
||||
...target,
|
||||
url: query
|
||||
? `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`
|
||||
: target.url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
|
||||
return (await wsTargetForPath(wsPath))?.url ?? null;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return typeof error === "string" ? error : String(error);
|
||||
}
|
||||
|
||||
export function Loader({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-background">
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("session-sharing activate", () => {
|
||||
it("provides sessions.sharing and the guest resolver", async () => {
|
||||
server = await startServer();
|
||||
expect(server.mock.services.get("sessions.sharing")).toBeDefined();
|
||||
expect(typeof server.guests.resolve).toBe("function");
|
||||
expect(server.mock.httpRouters).toEqual([
|
||||
{ public: ["/resolve/:linkToken", "/guest/:token"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["db:own", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot check a share target's host access without hosts:read", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('alice', 'alice'), ('bob', 'bob'); INSERT INTO ssh_data (id) VALUES (1)",
|
||||
);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "hosts:read"),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
actor: "bob",
|
||||
});
|
||||
await activate(mock.ctx);
|
||||
await expect(
|
||||
mock.ctx.asUser("bob", () => mock.ctx.hosts.checkAccess(1, "connect")),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import http from "node:http";
|
||||
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { LiveSessionInfo } from "../../src/backend/live.js";
|
||||
import type {
|
||||
SessionGuestsV1,
|
||||
SessionSharingV1,
|
||||
} from "../../src/backend/services.js";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
/** A stand-in for the terminal's "ssh" sessions.live provider. */
|
||||
export function createFakeLive() {
|
||||
const sessions = new Map<string, LiveSessionInfo>();
|
||||
const calls: Array<[string, ...unknown[]]> = [];
|
||||
return {
|
||||
sessions,
|
||||
calls,
|
||||
addSession(id: string, userId: string, hostId = 1) {
|
||||
sessions.set(id, {
|
||||
id,
|
||||
userId,
|
||||
hostId,
|
||||
hostName: `host-${hostId}`,
|
||||
isConnected: true,
|
||||
createdAt: 1,
|
||||
tabInstanceId: `tab-${id}`,
|
||||
});
|
||||
},
|
||||
provider: {
|
||||
getSession: (id: string) => sessions.get(id) ?? null,
|
||||
ownerEndSession: (...args: unknown[]) => {
|
||||
calls.push(["ownerEndSession", ...args]);
|
||||
},
|
||||
disconnectParticipants: (...args: unknown[]) => {
|
||||
calls.push(["disconnectParticipants", ...args]);
|
||||
return 0;
|
||||
},
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
calls.push(["setRoomShareControl", ...args]);
|
||||
},
|
||||
listGuests: () => [{ label: "Guest 1" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
live: ReturnType<typeof createFakeLive>;
|
||||
sharing: SessionSharingV1;
|
||||
guests: SessionGuestsV1;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string | null; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* the way core mounts it: JSON parsed and the acting user set from the
|
||||
* request (none for a guest), everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
admins?: string[];
|
||||
hostIds?: number[];
|
||||
/** Hosts ctx.hosts.checkAccess grants. Defaults to every host. */
|
||||
accessibleHostIds?: number[];
|
||||
settings?: Record<string, unknown>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sync_id TEXT");
|
||||
},
|
||||
});
|
||||
const refs = await db.database.refs();
|
||||
db.database.refs = (async () => ({
|
||||
...(refs as object),
|
||||
hosts: sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey(),
|
||||
syncId: text("sync_id"),
|
||||
}),
|
||||
})) as typeof db.database.refs;
|
||||
for (const user of options.users ?? ["alice", "bob", "carol"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, ?)")
|
||||
.run(user, user, options.admins?.includes(user) ? 1 : 0);
|
||||
}
|
||||
const hostIds = options.hostIds ?? [1, 2];
|
||||
for (const id of hostIds) {
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(id);
|
||||
}
|
||||
|
||||
const live = createFakeLive();
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["session-sharing.use"],
|
||||
settings: options.settings,
|
||||
hosts: (options.accessibleHostIds ?? hostIds).map((id) => ({
|
||||
id,
|
||||
userId: "alice",
|
||||
name: `host-${id}`,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
folder: null,
|
||||
authType: "password",
|
||||
})) as never,
|
||||
services: { "sessions.live#ssh": live.provider },
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
live,
|
||||
sharing: mock.services.get("sessions.sharing") as SessionSharingV1,
|
||||
guests: mock.ctx.registry.consume<SessionGuestsV1>(
|
||||
"session-sharing.guests",
|
||||
)!,
|
||||
async request(method, path, { user = "alice", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(user ? { "x-test-user": user } : {}),
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) {
|
||||
await Promise.resolve(dispose()).catch(() => {});
|
||||
}
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a service call the way a consumer's handle does: as that user. */
|
||||
export function asUser<T>(
|
||||
server: TestServer,
|
||||
userId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return server.mock.ctx.asUser(userId, fn);
|
||||
}
|
||||
|
||||
export function expireShare(server: TestServer, shareId: string): void {
|
||||
server.db.sqlite
|
||||
.prepare("UPDATE p_session_sharing_shares SET expires_at = ? WHERE id = ?")
|
||||
.run(new Date(Date.now() - 60_000).toISOString(), shareId);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// The four tables as core's SQLite bootstrap created them before 2.9.0,
|
||||
// including the guest token column and its index added later.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE collab_rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
persistent INTEGER NOT NULL DEFAULT 0,
|
||||
presenter_user_id TEXT,
|
||||
stage_protocol TEXT,
|
||||
stage_host_id INTEGER,
|
||||
stage_share_id TEXT,
|
||||
guest_link_token TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE collab_room_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
room_role TEXT NOT NULL DEFAULT 'member',
|
||||
added_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX idx_session_shares_session_id ON session_shares (session_id);
|
||||
CREATE INDEX idx_session_shares_host_id ON session_shares (host_id);
|
||||
CREATE UNIQUE INDEX idx_collab_rooms_guest_token ON collab_rooms (guest_link_token);
|
||||
`;
|
||||
|
||||
const TABLES = {
|
||||
session_shares: "p_session_sharing_shares",
|
||||
session_share_participants: "p_session_sharing_share_participants",
|
||||
collab_rooms: "p_session_sharing_rooms",
|
||||
collab_room_members: "p_session_sharing_room_members",
|
||||
};
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const exists = (type: "table" | "index", name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = ? AND name = ?")
|
||||
.get(type, name);
|
||||
|
||||
function seedUsersAndHost(sqlite: TestDb["sqlite"]) {
|
||||
sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('alice', 'alice'), ('bob', 'bob')",
|
||||
);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
}
|
||||
|
||||
describe("adopting the session sharing tables", () => {
|
||||
it("keeps every row and index when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
seedUsersAndHost(sqlite);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite.exec(`
|
||||
INSERT INTO session_shares (id, host_id, owner_user_id, protocol, session_id, share_type, link_token, permission_level, expires_at, join_count)
|
||||
VALUES ('share-1', 7, 'alice', 'ssh', 'sess-1', 'link', 'tok-1', 'read-write', '2999-01-01T00:00:00.000Z', 3);
|
||||
INSERT INTO session_share_participants (share_id, user_id, guest_label) VALUES ('share-1', NULL, 'Guest');
|
||||
INSERT INTO collab_rooms (id, name, owner_user_id, persistent, stage_share_id, stage_host_id, guest_link_token)
|
||||
VALUES ('room-1', 'Standup', 'alice', 1, 'share-1', 7, 'guest-tok');
|
||||
INSERT INTO collab_room_members (room_id, user_id, room_role) VALUES ('room-1', 'alice', 'host'), ('room-1', 'bob', 'member');
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_session_sharing"]);
|
||||
for (const [legacy, adopted] of Object.entries(TABLES)) {
|
||||
expect(exists("table", legacy)).toBe(false);
|
||||
expect(exists("table", adopted)).toBe(true);
|
||||
}
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT id, link_token, permission_level, join_count FROM p_session_sharing_shares",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
id: "share-1",
|
||||
link_token: "tok-1",
|
||||
permission_level: "read-write",
|
||||
join_count: 3,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT name, persistent, stage_share_id, guest_link_token FROM p_session_sharing_rooms",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({
|
||||
name: "Standup",
|
||||
persistent: 1,
|
||||
stage_share_id: "share-1",
|
||||
guest_link_token: "guest-tok",
|
||||
});
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_session_sharing_room_members")
|
||||
.get(),
|
||||
).toEqual({ n: 2 });
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT guest_label FROM p_session_sharing_share_participants")
|
||||
.get(),
|
||||
).toEqual({ guest_label: "Guest" });
|
||||
|
||||
for (const index of [
|
||||
"idx_session_shares_session_id",
|
||||
"idx_session_shares_host_id",
|
||||
"idx_collab_rooms_guest_token",
|
||||
"idx_collab_rooms_owner",
|
||||
"idx_collab_room_members_user",
|
||||
]) {
|
||||
expect(exists("index", index)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the rename in the links between the tables", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
seedUsersAndHost(sqlite);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
},
|
||||
});
|
||||
const ddl = (name: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE name = ?")
|
||||
.get(name) as { sql: string }
|
||||
).sql;
|
||||
|
||||
expect(ddl("p_session_sharing_rooms")).toContain(
|
||||
'"p_session_sharing_shares"',
|
||||
);
|
||||
expect(ddl("p_session_sharing_room_members")).toContain(
|
||||
'"p_session_sharing_rooms"',
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the tables fresh when there are no legacy ones", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => seedUsersAndHost(sqlite),
|
||||
});
|
||||
|
||||
for (const [legacy, adopted] of Object.entries(TABLES)) {
|
||||
expect(exists("table", legacy)).toBe(false);
|
||||
expect(exists("table", adopted)).toBe(true);
|
||||
}
|
||||
|
||||
db.sqlite.exec(`
|
||||
INSERT INTO p_session_sharing_shares (id, host_id, owner_user_id, protocol, session_id, share_type, expires_at)
|
||||
VALUES ('s', 7, 'alice', 'ssh', 'x', 'room', '2999-01-01');
|
||||
INSERT INTO p_session_sharing_rooms (id, name, owner_user_id, stage_share_id) VALUES ('r', 'R', 'alice', 's');
|
||||
INSERT INTO p_session_sharing_room_members (room_id, user_id) VALUES ('r', 'bob');
|
||||
`);
|
||||
// The hand-kept set-null link: deleting the share clears the stage.
|
||||
db.sqlite.exec("DELETE FROM p_session_sharing_shares WHERE id = 's'");
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT stage_share_id FROM p_session_sharing_rooms")
|
||||
.get(),
|
||||
).toEqual({ stage_share_id: null });
|
||||
// And deleting a user cascades to their memberships.
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'bob'");
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_session_sharing_room_members")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function roomWithBob(s: TestServer, persistent = false) {
|
||||
const created = await s.request("POST", "/rooms", {
|
||||
body: { name: "Standup", persistent },
|
||||
});
|
||||
const roomId = created.body.room.id as string;
|
||||
await s.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { userIds: ["bob"] },
|
||||
});
|
||||
return roomId;
|
||||
}
|
||||
|
||||
async function present(s: TestServer, roomId: string, user = "alice") {
|
||||
s.live.addSession(`sess-${user}`, user);
|
||||
return s.request("POST", `/rooms/${roomId}/present`, {
|
||||
user,
|
||||
body: { protocol: "ssh", sessionId: `sess-${user}`, hostId: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("collab rooms", () => {
|
||||
it("resolves a synced host to the server ID only with connect access", async () => {
|
||||
server = await startServer({ hostIds: [41, 42], accessibleHostIds: [42] });
|
||||
server.db.sqlite.exec("UPDATE ssh_data SET sync_id = 'shared-host'");
|
||||
expect(await server.request("GET", "/meeting-host/shared-host")).toEqual({
|
||||
status: 200,
|
||||
body: { id: 42 },
|
||||
});
|
||||
server.db.sqlite.exec(
|
||||
"UPDATE ssh_data SET sync_id = 'private-host' WHERE id = 41",
|
||||
);
|
||||
expect(
|
||||
(await server.request("GET", "/meeting-host/private-host")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/meeting-host/missing-host")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/meeting-host/shared-host", { user: null }))
|
||||
.status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("creates a room with the creator as host and hides the guest token", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("POST", "/rooms", {
|
||||
body: { name: " Standup " },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.room).toMatchObject({
|
||||
name: "Standup",
|
||||
ownerUserId: "alice",
|
||||
guestLinkEnabled: false,
|
||||
});
|
||||
expect(res.body.room).not.toHaveProperty("guestLinkToken");
|
||||
|
||||
const detail = await server.request("GET", `/rooms/${res.body.room.id}`);
|
||||
expect(detail.body.isHost).toBe(true);
|
||||
expect(detail.body.members).toEqual([
|
||||
expect.objectContaining({ userId: "alice", roomRole: "host" }),
|
||||
]);
|
||||
expect(detail.body.eventsWsPath).toBe("/plugin-ws/ssh-terminal/terminal");
|
||||
expect(server.mock.audits.map((entry) => entry.action)).toContain(
|
||||
"collab_room_create",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps rooms private to members and invites to the host", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}`, { user: "carol" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/members`, {
|
||||
user: "bob",
|
||||
body: { userIds: ["carol"] },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("GET", "/rooms", { user: "bob" })).body.rooms,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("expands a role invite to its current members", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite.exec(
|
||||
"INSERT INTO roles (id, name) VALUES (5, 'ops'); INSERT INTO user_roles (user_id, role_id) VALUES ('carol', 5)",
|
||||
);
|
||||
const created = await server.request("POST", "/rooms", {
|
||||
body: { name: "Ops" },
|
||||
});
|
||||
const roomId = created.body.room.id;
|
||||
await server.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { roleIds: [5] },
|
||||
});
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}`, { user: "carol" }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { roleIds: [99] },
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("puts a member's live session on stage as a read-only room share", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
const res = await present(server, roomId, "bob");
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const stage = await server.request("GET", `/rooms/${roomId}/stage`);
|
||||
expect(stage.body.stage).toMatchObject({
|
||||
presenterUserId: "bob",
|
||||
protocol: "ssh",
|
||||
sessionId: "sess-bob",
|
||||
});
|
||||
const row = server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT share_type, permission_level FROM p_session_sharing_shares",
|
||||
)
|
||||
.get();
|
||||
expect(row).toEqual({ share_type: "room", permission_level: "read-only" });
|
||||
});
|
||||
|
||||
it("refuses to present someone else's session", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
server.live.addSession("sess-alice", "alice");
|
||||
const res = await server.request("POST", `/rooms/${roomId}/present`, {
|
||||
user: "bob",
|
||||
body: { protocol: "ssh", sessionId: "sess-alice", hostId: 1 },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("clears a stage whose session died", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
server.live.sessions.delete("sess-alice");
|
||||
|
||||
const stage = await server.request("GET", `/rooms/${roomId}/stage`, {
|
||||
user: "bob",
|
||||
});
|
||||
expect(stage.body.stage).toBeNull();
|
||||
const detail = await server.request("GET", `/rooms/${roomId}`);
|
||||
expect(detail.body.stage.shareId).toBeNull();
|
||||
});
|
||||
|
||||
it("hands control only from the presenter or host, and only to members", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
user: "bob",
|
||||
body: { userId: "bob" },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
body: { userId: "carol" },
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
|
||||
const granted = await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
body: { userId: "bob" },
|
||||
});
|
||||
expect(granted.body).toEqual({ controllerUserId: "bob" });
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"setRoomShareControl",
|
||||
"sess-alice",
|
||||
expect.any(String),
|
||||
"bob",
|
||||
]);
|
||||
|
||||
// The controller may give it back.
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
user: "bob",
|
||||
body: { userId: null },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("rate limits hand raises", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
const first = await server.request(
|
||||
"POST",
|
||||
`/rooms/${roomId}/control/request`,
|
||||
{ user: "bob" },
|
||||
);
|
||||
expect(first.body.request).toMatchObject({
|
||||
userId: "bob",
|
||||
username: "bob",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control/request`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(429);
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/rooms/${roomId}/control/requests`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}/control/requests`)).body
|
||||
.requests,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes a member and drops them from the stage", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
expect(
|
||||
(await server.request("DELETE", `/rooms/${roomId}/members/alice`)).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await server.request("DELETE", `/rooms/${roomId}/members/bob`)).status,
|
||||
).toBe(200);
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"disconnectParticipants",
|
||||
"sess-alice",
|
||||
expect.any(String),
|
||||
{
|
||||
userId: "bob",
|
||||
reason: "You were removed from the collaboration room",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists stage guests for members and lets only the host remove them", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
const detail = await server.request("GET", `/rooms/${roomId}`, {
|
||||
user: "bob",
|
||||
});
|
||||
expect(detail.body.guests).toEqual([{ label: "Guest 1" }]);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/guests/remove`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("POST", `/rooms/${roomId}/guests/remove`)).status,
|
||||
).toBe(200);
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"disconnectParticipants",
|
||||
"sess-alice",
|
||||
expect.any(String),
|
||||
{ userId: null, reason: "The host removed the guests" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a one-off room for good and keeps a persistent one", async () => {
|
||||
server = await startServer();
|
||||
const oneOff = await roomWithBob(server);
|
||||
await present(server, oneOff);
|
||||
expect(
|
||||
(await server.request("POST", `/rooms/${oneOff}/end`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
await server.request("POST", `/rooms/${oneOff}/end`);
|
||||
expect((await server.request("GET", `/rooms/${oneOff}`)).status).toBe(404);
|
||||
|
||||
const persistent = await roomWithBob(server, true);
|
||||
await server.request("POST", `/rooms/${persistent}/end`);
|
||||
expect((await server.request("GET", `/rooms/${persistent}`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a room and its members", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
expect((await server.request("DELETE", `/rooms/${roomId}`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_session_sharing_room_members")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Guest tokens, participant permissions and expiry. These are what keep a
|
||||
* share link from becoming a way into someone else's session, so each rule
|
||||
* the old core code enforced has a test here.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { asUser, expireShare, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function createShare(
|
||||
s: TestServer,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<{ shareId: string; linkToken: string | null }> {
|
||||
if (!s.live.sessions.has("sess-1")) s.live.addSession("sess-1", "alice");
|
||||
const res = await s.request("POST", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...body,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
async function roomOnStage(s: TestServer) {
|
||||
const created = await s.request("POST", "/rooms", { body: { name: "R" } });
|
||||
const roomId = created.body.room.id as string;
|
||||
await s.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { userIds: ["bob"] },
|
||||
});
|
||||
s.live.addSession("sess-stage", "alice");
|
||||
await s.request("POST", `/rooms/${roomId}/present`, {
|
||||
body: { protocol: "ssh", sessionId: "sess-stage", hostId: 1 },
|
||||
});
|
||||
const link = await s.request("POST", `/rooms/${roomId}/guest-link`, {
|
||||
body: { enabled: true },
|
||||
});
|
||||
return { roomId, token: link.body.guestLinkToken as string };
|
||||
}
|
||||
|
||||
describe("share link guests", () => {
|
||||
it("resolves a live link without any host details", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server, {
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
const res = await server.request("GET", `/resolve/${linkToken}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-write",
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${linkToken}`,
|
||||
});
|
||||
|
||||
const joined = await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
});
|
||||
expect(joined).toEqual({
|
||||
ok: true,
|
||||
share: expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
permissionLevel: "read-write",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a read-only link read-only", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server);
|
||||
const joined = await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
});
|
||||
expect(joined).toMatchObject({ share: { permissionLevel: "read-only" } });
|
||||
});
|
||||
|
||||
it("refuses an unknown or guessed token", async () => {
|
||||
server = await startServer();
|
||||
await createShare(server);
|
||||
for (const token of ["nope", "", "%00"]) {
|
||||
const res = await server.request("GET", `/resolve/${token || "x"}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({ shareToken: token, clientIp: "1.1.1.1" }),
|
||||
).toMatchObject({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a revoked link", async () => {
|
||||
server = await startServer();
|
||||
const { shareId, linkToken } = await createShare(server);
|
||||
await server.request("DELETE", `/${shareId}`);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "Invalid or expired share link" });
|
||||
});
|
||||
|
||||
it("refuses a link once sharing is switched off", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server);
|
||||
await server.mock.ctx.settings.set("globallyEnabled", false);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("answers 404 once the session is gone", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server);
|
||||
server.live.sessions.delete("sess-1");
|
||||
const res = await server.request("GET", `/resolve/${linkToken}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rate limits resolves per IP", async () => {
|
||||
server = await startServer();
|
||||
let last = 0;
|
||||
for (let i = 0; i < 31; i++) {
|
||||
last = (await server.request("GET", "/resolve/guess", { user: null }))
|
||||
.status;
|
||||
}
|
||||
expect(last).toBe(429);
|
||||
});
|
||||
|
||||
it("records a guest join against the share", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server);
|
||||
await server.guests.recordJoin(shareId);
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT join_count, (SELECT guest_label FROM p_session_sharing_share_participants) AS label FROM p_session_sharing_shares",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ join_count: 1, label: "Guest" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("expired shares", () => {
|
||||
it("stops resolving a link once it expires", async () => {
|
||||
server = await startServer();
|
||||
const { shareId, linkToken } = await createShare(server);
|
||||
expireShare(server, shareId);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
expect((await server.request("GET", "/host/1/active")).body.shares).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a member join on an expired user share", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server, {
|
||||
shareType: "user",
|
||||
targetUserId: "bob",
|
||||
});
|
||||
expireShare(server, shareId);
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a room stage whose share expired and stops guests", async () => {
|
||||
server = await startServer();
|
||||
const { roomId, token } = await roomOnStage(server);
|
||||
const shareId = (await server.request("GET", `/rooms/${roomId}`)).body.stage
|
||||
.shareId;
|
||||
expireShare(server, shareId);
|
||||
|
||||
const guest = await server.request("GET", `/guest/${token}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(guest.body).toEqual({ roomName: "R", stage: null });
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
roomGuestToken: token,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "Nothing is being presented" });
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}/stage`)).body.stage,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("member joins", () => {
|
||||
it("lets only the target of a user share join", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server, {
|
||||
shareType: "user",
|
||||
targetUserId: "bob",
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toEqual({
|
||||
share: {
|
||||
id: shareId,
|
||||
sessionId: "sess-1",
|
||||
permissionLevel: "read-write",
|
||||
},
|
||||
displayName: "bob",
|
||||
});
|
||||
expect(
|
||||
await asUser(server, "carol", () =>
|
||||
server!.sharing.authorizeJoin(shareId),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses the target once they lose access to the host", async () => {
|
||||
server = await startServer({ accessibleHostIds: [2] });
|
||||
server.live.addSession("sess-1", "alice", 1);
|
||||
// Inserted directly: bob could reach host 1 when it was shared with him.
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_session_sharing_shares (id, host_id, owner_user_id, protocol, session_id, share_type, target_user_id, expires_at) VALUES ('s', 1, 'alice', 'ssh', 'sess-1', 'user', 'bob', ?)",
|
||||
)
|
||||
.run(new Date(Date.now() + 60_000).toISOString());
|
||||
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin("s")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a link share joined as a member, and no user at all", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server);
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toBeNull();
|
||||
expect(await server.sharing.authorizeJoin(shareId)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets room members, and only them, join a room stage read-only", async () => {
|
||||
server = await startServer();
|
||||
const { roomId } = await roomOnStage(server);
|
||||
const shareId = (await server.request("GET", `/rooms/${roomId}`)).body.stage
|
||||
.shareId;
|
||||
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toMatchObject({ share: { permissionLevel: "read-only" } });
|
||||
expect(
|
||||
await asUser(server, "carol", () =>
|
||||
server!.sharing.authorizeJoin(shareId),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes only members to room events", async () => {
|
||||
server = await startServer();
|
||||
const { roomId } = await roomOnStage(server);
|
||||
const socket = { readyState: 1, OPEN: 1, send: () => {} };
|
||||
expect(
|
||||
await asUser(server, "carol", () =>
|
||||
server!.sharing.subscribeRoom(roomId, socket),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await asUser(server, "bob", () =>
|
||||
server!.sharing.subscribeRoom(roomId, socket),
|
||||
),
|
||||
).toBe(true);
|
||||
await server.sharing.unsubscribeRoom(socket);
|
||||
});
|
||||
});
|
||||
|
||||
describe("room guest links", () => {
|
||||
it("resolves the stage for a guest without host details", async () => {
|
||||
server = await startServer();
|
||||
const { token } = await roomOnStage(server);
|
||||
|
||||
const res = await server.request("GET", `/guest/${token}`, { user: null });
|
||||
expect(res.body).toEqual({
|
||||
roomName: "R",
|
||||
stage: {
|
||||
protocol: "ssh",
|
||||
shareId: expect.any(String),
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${token}`,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
roomGuestToken: token,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: true, share: { permissionLevel: "read-only" } });
|
||||
});
|
||||
|
||||
it("refuses the old token after a rotate and any token after disable", async () => {
|
||||
server = await startServer();
|
||||
const { roomId, token } = await roomOnStage(server);
|
||||
|
||||
const rotated = await server.request(
|
||||
"POST",
|
||||
`/rooms/${roomId}/guest-link`,
|
||||
{
|
||||
body: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await server.request("GET", `/guest/${token}`, { user: null })).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
roomGuestToken: token,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"disconnectParticipants",
|
||||
"sess-stage",
|
||||
expect.any(String),
|
||||
{ userId: null, reason: "The guest link was rotated" },
|
||||
]);
|
||||
|
||||
await server.request("POST", `/rooms/${roomId}/guest-link`, {
|
||||
body: { enabled: false },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/guest/${rotated.body.guestLinkToken}`, {
|
||||
user: null,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses a guest link after the room ends", async () => {
|
||||
server = await startServer();
|
||||
const { roomId, token } = await roomOnStage(server);
|
||||
await server.request("POST", `/rooms/${roomId}/end`);
|
||||
expect(
|
||||
(await server.request("GET", `/guest/${token}`, { user: null })).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("does not accept a room guest token as a share link, or the reverse", async () => {
|
||||
server = await startServer();
|
||||
const { token } = await roomOnStage(server);
|
||||
const { linkToken } = await createShare(server);
|
||||
|
||||
expect(
|
||||
await server.guests.resolve({ shareToken: token, clientIp: "1.1.1.1" }),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(
|
||||
(await server.request("GET", `/guest/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("only lets the host manage the guest link", async () => {
|
||||
server = await startServer();
|
||||
const { roomId } = await roomOnStage(server);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/guest-link`, {
|
||||
user: "bob",
|
||||
body: { enabled: true },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("rate limits room guests per IP", async () => {
|
||||
server = await startServer();
|
||||
let refused = false;
|
||||
for (let i = 0; i < 61; i++) {
|
||||
const result = await server.guests.resolve({
|
||||
roomGuestToken: "guess",
|
||||
clientIp: "9.9.9.9",
|
||||
});
|
||||
if (result.ok === false && result.reason === "Too many requests") {
|
||||
refused = true;
|
||||
}
|
||||
}
|
||||
expect(refused).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const linkShare = (overrides: Record<string, unknown> = {}) => ({
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("share routes", () => {
|
||||
it("creates a link share for a session the caller owns", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
|
||||
const res = await server.request("POST", "/create", { body: linkShare() });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.linkToken).toMatch(/^[A-Za-z0-9_-]{32}$/);
|
||||
const active = await server.request("GET", "/host/1/active");
|
||||
expect(active.body.shares).toHaveLength(1);
|
||||
expect(active.body.shares[0]).toMatchObject({
|
||||
ownerUserId: "alice",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a session someone else owns", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "bob");
|
||||
|
||||
const res = await server.request("POST", "/create", { body: linkShare() });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses when sharing is off for the host or everywhere", async () => {
|
||||
server = await startServer({ settings: { globallyEnabled: false } });
|
||||
server.live.addSession("sess-1", "alice");
|
||||
expect(
|
||||
(await server.request("POST", "/create", { body: linkShare() })).status,
|
||||
).toBe(403);
|
||||
await server.close();
|
||||
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
await server.mock.ctx.settings.setHost(1, "allowSessionSharing", false);
|
||||
expect(
|
||||
(await server.request("POST", "/create", { body: linkShare() })).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("validates the request", async () => {
|
||||
server = await startServer();
|
||||
for (const body of [
|
||||
{},
|
||||
linkShare({ protocol: "ftp" }),
|
||||
linkShare({ shareType: "room" }),
|
||||
linkShare({ permissionLevel: "owner" }),
|
||||
linkShare({ shareType: "user" }),
|
||||
]) {
|
||||
expect((await server.request("POST", "/create", { body })).status).toBe(
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shares with a user only when they can reach the host", async () => {
|
||||
server = await startServer({ accessibleHostIds: [1] });
|
||||
server.live.addSession("sess-1", "alice", 1);
|
||||
server.live.addSession("sess-2", "alice", 2);
|
||||
|
||||
const ok = await server.request("POST", "/create", {
|
||||
body: linkShare({ shareType: "user", targetUserId: "bob" }),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.linkToken).toBeNull();
|
||||
|
||||
const noAccess = await server.request("POST", "/create", {
|
||||
body: linkShare({
|
||||
hostId: 2,
|
||||
sessionId: "sess-2",
|
||||
shareType: "user",
|
||||
targetUserId: "bob",
|
||||
}),
|
||||
});
|
||||
expect(noAccess.status).toBe(403);
|
||||
|
||||
const unknownUser = await server.request("POST", "/create", {
|
||||
body: linkShare({ shareType: "user", targetUserId: "nobody" }),
|
||||
});
|
||||
expect(unknownUser.status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets the owner or an admin revoke, and kicks SSH guests", async () => {
|
||||
server = await startServer({ admins: ["carol"] });
|
||||
server.live.addSession("sess-1", "alice");
|
||||
const { body } = await server.request("POST", "/create", {
|
||||
body: linkShare(),
|
||||
});
|
||||
|
||||
expect(
|
||||
(await server.request("DELETE", `/${body.shareId}`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("DELETE", `/${body.shareId}`, { user: "carol" }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect(server.live.calls[0]).toEqual([
|
||||
"ownerEndSession",
|
||||
"sess-1",
|
||||
"Session share revoked by owner",
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/resolve/${body.linkToken}`, {
|
||||
user: null,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect((await server.request("DELETE", "/missing")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("ends a shared session for its owner only", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
const { body } = await server.request("POST", "/create", {
|
||||
body: linkShare(),
|
||||
});
|
||||
|
||||
expect(
|
||||
(await server.request("POST", `/${body.shareId}/end`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect((await server.request("POST", `/${body.shareId}/end`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"ownerEndSession",
|
||||
"sess-1",
|
||||
"Session ended by owner",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists live sessions shared with the caller", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
const { body } = await server.request("POST", "/create", {
|
||||
body: linkShare({ shareType: "user", targetUserId: "bob" }),
|
||||
});
|
||||
|
||||
const res = await server.request("GET", "/shared-with-me", { user: "bob" });
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
hostName: "host-1",
|
||||
isOwnSession: false,
|
||||
sharedByUsername: "alice",
|
||||
shareId: body.shareId,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
]);
|
||||
|
||||
server.live.sessions.delete("sess-1");
|
||||
expect(
|
||||
(await server.request("GET", "/shared-with-me", { user: "bob" })).body,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists users and roles for the pickers", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite
|
||||
.prepare("INSERT INTO roles (id, name, display_name) VALUES (?, ?, ?)")
|
||||
.run(3, "ops", "Operations");
|
||||
const res = await server.request("GET", "/directory");
|
||||
expect(res.body.users.map((u: { id: string }) => u.id)).toEqual([
|
||||
"alice",
|
||||
"bob",
|
||||
"carol",
|
||||
]);
|
||||
expect(res.body.roles).toEqual([
|
||||
{ id: 3, name: "ops", displayName: "Operations" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("denies every signed-in route without the use permission", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
for (const [method, path] of [
|
||||
["POST", "/create"],
|
||||
["GET", "/host/1/active"],
|
||||
["GET", "/shared-with-me"],
|
||||
["GET", "/directory"],
|
||||
["DELETE", "/share-1"],
|
||||
["POST", "/share-1/end"],
|
||||
["POST", "/rooms"],
|
||||
["GET", "/rooms"],
|
||||
["GET", "/rooms/r"],
|
||||
["POST", "/rooms/r/members"],
|
||||
["DELETE", "/rooms/r/members/bob"],
|
||||
["POST", "/rooms/r/present"],
|
||||
["POST", "/rooms/r/stop"],
|
||||
["GET", "/rooms/r/stage"],
|
||||
["POST", "/rooms/r/control"],
|
||||
["POST", "/rooms/r/control/request"],
|
||||
["GET", "/rooms/r/control/requests"],
|
||||
["DELETE", "/rooms/r/control/requests/bob"],
|
||||
["POST", "/rooms/r/guest-link"],
|
||||
["POST", "/rooms/r/end"],
|
||||
["DELETE", "/rooms/r"],
|
||||
]) {
|
||||
const res = await server.request(method, path, { body: {} });
|
||||
expect([method, path, res.status]).toEqual([method, path, 403]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginComponent } from "@termix/plugin-sdk/ui";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import SharedSessionView from "../../src/frontend/SharedSessionView";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
vi.mock("react-xtermjs", () => ({
|
||||
useXTerm: () => ({
|
||||
instance: {
|
||||
options: {},
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
},
|
||||
ref: { current: document.createElement("div") },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class FitAddon {
|
||||
fit() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
const guestView = locales.sessionSharing.guestView;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function httpError(status: number) {
|
||||
return Object.assign(new Error(`HTTP ${status}`), { response: { status } });
|
||||
}
|
||||
|
||||
/** The shared-link page, rendered as a guest inside the plugin's scope. */
|
||||
async function renderShared(search: string, get: PluginApiClient["get"]) {
|
||||
window.history.pushState({}, "", `/?${search}`);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
guest: true,
|
||||
api: { get } as unknown as PluginApiClient,
|
||||
});
|
||||
rendered.app.registerComponent(
|
||||
"session-sharing.testSharedView",
|
||||
SharedSessionView as ComponentType<Record<string, unknown>>,
|
||||
);
|
||||
rendered.app.registerSlotContribution("session.remoteDisplay", {
|
||||
actionId: "fixture.display",
|
||||
titleKey: "nav.collab",
|
||||
kind: "component",
|
||||
component: () => <div data-testid="remote-display" />,
|
||||
});
|
||||
render(<PluginComponent id="session-sharing.testSharedView" />);
|
||||
}
|
||||
|
||||
describe("SharedSessionView", () => {
|
||||
it("says the link is invalid when there is no token", async () => {
|
||||
const get = vi.fn();
|
||||
await renderShared("view=shared", get as never);
|
||||
expect(await screen.findByText(guestView.linkInvalid)).toBeTruthy();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the link is invalid for a revoked or expired link", async () => {
|
||||
await renderShared(
|
||||
"view=shared&token=gone",
|
||||
vi.fn(async () => {
|
||||
throw httpError(404);
|
||||
}) as never,
|
||||
);
|
||||
expect(await screen.findByText(guestView.linkInvalid)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks the guest to wait when rate limited", async () => {
|
||||
await renderShared(
|
||||
"view=shared&token=busy",
|
||||
vi.fn(async () => {
|
||||
throw httpError(429);
|
||||
}) as never,
|
||||
);
|
||||
expect(await screen.findByText(guestView.rateLimited)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws a remote desktop share through the display slot", async () => {
|
||||
const get = vi.fn(async () => ({
|
||||
data: {
|
||||
protocol: "rdp",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: "/plugin-ws/remote-desktop/display",
|
||||
connectParams: { token: "t" },
|
||||
},
|
||||
}));
|
||||
await renderShared("view=shared&token=ok", get as never);
|
||||
expect(await screen.findByTestId("remote-display")).toBeTruthy();
|
||||
expect(screen.getByText(guestView.readOnlyBadge)).toBeTruthy();
|
||||
expect(get).toHaveBeenCalledWith("/resolve/ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getSharedWithMe: vi.fn(async () => [] as unknown[]),
|
||||
}));
|
||||
const sdk = vi.hoisted(() => ({
|
||||
invokeAction: vi.fn(async () => undefined),
|
||||
allowed: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/frontend/api", () => api);
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importActual) => ({
|
||||
...(await importActual<typeof import("@termix/plugin-sdk/frontend")>()),
|
||||
invokeAction: sdk.invokeAction,
|
||||
usePermission: () => sdk.allowed,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options ? `${key}:${JSON.stringify(options)}` : key,
|
||||
language: "en",
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SharedWithMeSection } from "../../src/frontend/SharedWithMeSection";
|
||||
|
||||
function shared(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sessionId: "sess-1",
|
||||
hostId: 5,
|
||||
hostName: "prod-db",
|
||||
isConnected: true,
|
||||
sharedByUsername: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "share-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api.getSharedWithMe.mockReset();
|
||||
sdk.invokeAction.mockClear();
|
||||
sdk.allowed = true;
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("shared with me section", () => {
|
||||
it("lists sessions shared with the user", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(<SharedWithMeSection />);
|
||||
|
||||
expect(await screen.findByText("prod-db")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('connections.sharedBy:{"username":"alice"}'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("sessionSharing.permissionLevel.readOnly"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides a session the user already joined in a tab", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(
|
||||
<SharedWithMeSection openTabData={[{ joinSharedSessionId: "sess-1" }]} />,
|
||||
);
|
||||
await vi.waitFor(() => expect(api.getSharedWithMe).toHaveBeenCalled());
|
||||
expect(screen.queryByText("prod-db")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by the connections search", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([
|
||||
shared(),
|
||||
shared({ sessionId: "sess-2", hostName: "web", sharedByUsername: "bob" }),
|
||||
]);
|
||||
render(<SharedWithMeSection search="bob" />);
|
||||
expect(await screen.findByText("web")).toBeTruthy();
|
||||
expect(screen.queryByText("prod-db")).toBeNull();
|
||||
});
|
||||
|
||||
it("joins through the terminal's open action", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(<SharedWithMeSection />);
|
||||
fireEvent.click(await screen.findByText("connections.join"));
|
||||
|
||||
expect(sdk.invokeAction).toHaveBeenCalledWith(
|
||||
"terminal.open",
|
||||
expect.objectContaining({ id: "5", name: "prod-db" }),
|
||||
expect.objectContaining({
|
||||
joinSharedSessionId: "sess-1",
|
||||
joinShareId: "share-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a user without the use permission", async () => {
|
||||
sdk.allowed = false;
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
const { container } = render(<SharedWithMeSection />);
|
||||
expect(container.textContent).toBe("");
|
||||
expect(api.getSharedWithMe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
invokeAction,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
|
||||
return {
|
||||
get: vi.fn(async (url: string) => ({
|
||||
data:
|
||||
url === "/directory"
|
||||
? { users: [{ id: "bob", username: "bob" }], roles: [] }
|
||||
: url.includes("/active")
|
||||
? { shares: [] }
|
||||
: url === "/rooms"
|
||||
? { rooms: [] }
|
||||
: {},
|
||||
})),
|
||||
post: vi.fn(async () => ({
|
||||
data: { shareId: "s1", linkToken: "tok", expiresAt: "2999-01-01" },
|
||||
})),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: {} })),
|
||||
...overrides,
|
||||
} as PluginApiClient;
|
||||
}
|
||||
|
||||
describe("session-sharing activate", () => {
|
||||
it("registers the rooms rail item, panel and tab, and the share buttons", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
|
||||
expect(rendered.registered.railItems()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "collab",
|
||||
permission: "session-sharing.use",
|
||||
}),
|
||||
]);
|
||||
expect(rendered.registered.panels()).toEqual(["collab"]);
|
||||
expect(rendered.registered.tabs()).toEqual(["collab"]);
|
||||
expect(rendered.registered.slot("terminal.toolbar")).toEqual([
|
||||
"session-sharing.share",
|
||||
]);
|
||||
expect(rendered.registered.slot("remote-desktop.toolbar")).toEqual([
|
||||
"session-sharing.share",
|
||||
]);
|
||||
expect(rendered.registered.slot("tab.inline")).toEqual([
|
||||
"session-sharing.share",
|
||||
]);
|
||||
expect(rendered.registered.slot("shell.overlay")).toHaveLength(2);
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"session-sharing.share",
|
||||
]);
|
||||
expect(rendered.registered.slot("connections.sections")).toEqual([
|
||||
"session-sharing.sharedWithMe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows terminal participants and clears them after the last guest leaves or the socket closes", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
expect(rendered.registered.slot("terminal.overlay")).toEqual([
|
||||
"session-sharing.participants",
|
||||
]);
|
||||
let receive: (message: {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}) => void = () => {};
|
||||
const unsubscribe = vi.fn();
|
||||
rendered.renderSlot("terminal.overlay", {
|
||||
subscribe: (listener: typeof receive) => {
|
||||
receive = listener;
|
||||
return unsubscribe;
|
||||
},
|
||||
});
|
||||
const owner = { isOwner: true, permissionLevel: "read-write", label: null };
|
||||
const guest = {
|
||||
isOwner: false,
|
||||
permissionLevel: "read-only",
|
||||
label: "Guest 1",
|
||||
};
|
||||
await act(() =>
|
||||
receive({ type: "participants", participants: [owner, guest] }),
|
||||
);
|
||||
expect(
|
||||
screen.getByTitle(
|
||||
`${locales.sessionSharing.guestView.ownerLabel}, Guest 1`,
|
||||
).textContent,
|
||||
).toBe("2");
|
||||
await act(() => receive({ type: "participants", participants: [owner] }));
|
||||
expect(screen.queryByText("2")).toBeNull();
|
||||
await act(() =>
|
||||
receive({ type: "participants", participants: [owner, guest] }),
|
||||
);
|
||||
await act(() => receive({ type: "session_closed" }));
|
||||
expect(screen.queryByText("2")).toBeNull();
|
||||
await rendered.deactivate();
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
it("registers only the guest views on a guest page", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
guest: true,
|
||||
});
|
||||
expect(rendered.registered.tabs()).toEqual(["collab"]);
|
||||
expect(rendered.registered.railItems()).toEqual([]);
|
||||
expect(rendered.registered.actions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens the share dialog for a terminal's session", async () => {
|
||||
const api = stubApi();
|
||||
rendered = await renderWithApp(plugin, { manifest, locales, api });
|
||||
rendered.renderSlot("shell.overlay");
|
||||
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
getShareTarget: () => ({
|
||||
hostId: 4,
|
||||
sessionId: "sess-9",
|
||||
protocol: "ssh",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(locales.sessionSharing.modalTitle),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalledWith("/host/4/active"));
|
||||
});
|
||||
|
||||
it("opens the dialog for a remote desktop session once it is up", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
rendered.renderSlot("shell.overlay");
|
||||
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
hostId: 2,
|
||||
sessionId: null,
|
||||
protocol: "rdp",
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText(locales.sessionSharing.modalTitle)).toBeNull();
|
||||
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
hostId: 2,
|
||||
sessionId: "guac-1",
|
||||
protocol: "rdp",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(locales.sessionSharing.modalTitle),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a link share from the dialog", async () => {
|
||||
const api = stubApi();
|
||||
rendered = await renderWithApp(plugin, { manifest, locales, api });
|
||||
rendered.renderSlot("shell.overlay");
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
getShareTarget: () => ({
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
protocol: "ssh",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: locales.sessionSharing.createLinkButton,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/create",
|
||||
expect.objectContaining({
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.slot("terminal.toolbar")).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GuestTerminalView } from "../../src/frontend/SharedSessionView";
|
||||
|
||||
const f = vi.hoisted(() => ({
|
||||
terminal: {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {},
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
},
|
||||
fit: vi.fn(),
|
||||
onResize: null as (() => void) | null,
|
||||
}));
|
||||
vi.mock("@termix/plugin-sdk/frontend", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("react-xtermjs", () => ({
|
||||
useXTerm: () => ({ instance: f.terminal, ref: { current: null } }),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class {
|
||||
fit() {
|
||||
f.fit();
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("../../src/frontend/shared", () => ({
|
||||
wsUrlForPath: async () => "ws://termix.test/terminal",
|
||||
RemoteDisplay: () => null,
|
||||
Loader: () => null,
|
||||
}));
|
||||
class Socket {
|
||||
static OPEN = 1;
|
||||
static latest: Socket;
|
||||
readyState = 1;
|
||||
onopen?: () => void;
|
||||
onmessage?: (event: { data: string }) => void;
|
||||
onclose?: () => void;
|
||||
onerror?: () => void;
|
||||
constructor() {
|
||||
Socket.latest = this;
|
||||
}
|
||||
send() {}
|
||||
close() {}
|
||||
message(value: object) {
|
||||
this.onmessage?.({ data: JSON.stringify(value) });
|
||||
}
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.terminal.cols = 80;
|
||||
f.terminal.rows = 24;
|
||||
f.terminal.resize.mockImplementation((cols: number, rows: number) => {
|
||||
f.terminal.cols = cols;
|
||||
f.terminal.rows = rows;
|
||||
});
|
||||
f.fit.mockImplementation(() => {
|
||||
f.terminal.cols = 80;
|
||||
f.terminal.rows = 24;
|
||||
});
|
||||
vi.stubGlobal("WebSocket", Socket);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(fn: () => void) {
|
||||
f.onResize = fn;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the presenter's grid while the guest window changes size", async () => {
|
||||
render(
|
||||
<GuestTerminalView
|
||||
share={{ permissionLevel: "read-only" }}
|
||||
wsPath="/plugin-ws/ssh-terminal/terminal?shareToken=test"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
|
||||
act(() => Socket.latest.message({ type: "resized", cols: 132, rows: 40 }));
|
||||
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
|
||||
act(() => f.onResize?.());
|
||||
expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
|
||||
act(() => Socket.latest.message({ type: "resized", cols: 100, rows: 30 }));
|
||||
expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
|
||||
});
|
||||
|
||||
describe("invalid shared terminal dimensions", () => {
|
||||
it.each([0, -1, 1.5, "132"])("ignores cols=%s", async (cols) => {
|
||||
render(
|
||||
<GuestTerminalView
|
||||
share={{ permissionLevel: "read-only" }}
|
||||
wsPath="/plugin-ws/ssh-terminal/terminal?shareToken=test"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
|
||||
act(() => Socket.latest.message({ type: "resized", cols, rows: 24 }));
|
||||
expect(f.terminal.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
connectMeetings,
|
||||
meetingGuestUrl,
|
||||
} from "../../src/frontend/meeting-backend";
|
||||
|
||||
function client(): PluginApiClient {
|
||||
return Object.fromEntries(
|
||||
["get", "post", "put", "patch", "delete"].map((method) => [
|
||||
method,
|
||||
vi.fn(async () => ({ data: { rooms: [] } })),
|
||||
]),
|
||||
) as unknown as PluginApiClient;
|
||||
}
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("meeting backend", () => {
|
||||
it("routes rooms, invites and host resolution to the linked server and refuses a later switch", async () => {
|
||||
const local = client();
|
||||
const remote = client();
|
||||
let url: string | null = "https://termix.example/base/";
|
||||
const app = {
|
||||
desktop: { available: true, remoteServerUrl: async () => url },
|
||||
apiFor: (origin: string) => (origin === "remote" ? remote : local),
|
||||
t: (key: string) => key,
|
||||
} as unknown as TermixApp;
|
||||
const backend = await connectMeetings(app);
|
||||
expect(backend.origin).toBe("remote");
|
||||
await backend.api.listCollabRooms();
|
||||
await backend.api.createCollabRoom("Standup", true);
|
||||
await backend.api.getDirectory();
|
||||
await backend.api.resolveHost("stable-host-id");
|
||||
await backend.api.presentCollabStage("room", {
|
||||
hostId: 99,
|
||||
sessionId: "remote-session",
|
||||
protocol: "ssh",
|
||||
});
|
||||
expect(local.get).not.toHaveBeenCalled();
|
||||
expect(local.post).not.toHaveBeenCalled();
|
||||
expect(remote.get).toHaveBeenCalledWith("/meeting-host/stable-host-id");
|
||||
expect(remote.post).toHaveBeenCalledWith("/rooms/room/present", {
|
||||
hostId: 99,
|
||||
sessionId: "remote-session",
|
||||
protocol: "ssh",
|
||||
});
|
||||
expect(meetingGuestUrl(backend.publicUrl, "a+b&c")).toBe(
|
||||
"https://termix.example/base/?view=collab-guest&token=a%2Bb%26c",
|
||||
);
|
||||
url = "https://other.example";
|
||||
await expect(backend.api.deleteCollabRoom("room")).rejects.toThrow(
|
||||
"collab.serverChanged",
|
||||
);
|
||||
expect(remote.delete).not.toHaveBeenCalled();
|
||||
url = null;
|
||||
await expect(backend.api.getCollabRoom("room")).rejects.toThrow(
|
||||
"collab.serverChanged",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps standalone rooms local without generating a file URL", async () => {
|
||||
const local = client();
|
||||
const backend = await connectMeetings({
|
||||
desktop: { available: true, remoteServerUrl: async () => null },
|
||||
apiFor: () => local,
|
||||
t: (key: string) => key,
|
||||
} as unknown as TermixApp);
|
||||
expect(backend.origin).toBe("local");
|
||||
await backend.api.listCollabRooms();
|
||||
expect(local.get).toHaveBeenCalledWith("/rooms");
|
||||
expect(meetingGuestUrl(backend.publicUrl, "token")).toBeNull();
|
||||
expect(meetingGuestUrl("file:///app/index.html", "token")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves web deployment paths while replacing old query and fragment values", () => {
|
||||
expect(
|
||||
meetingGuestUrl("https://termix.example/app/?view=other#old", "token"),
|
||||
).toBe("https://termix.example/app/?view=collab-guest&token=token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { pluginWsUrl } = vi.hoisted(() => ({ pluginWsUrl: vi.fn() }));
|
||||
vi.mock("@termix/plugin-sdk/ui", () => ({ pluginWsUrl }));
|
||||
import { wsTargetForPath } from "../../src/frontend/shared";
|
||||
|
||||
describe("meeting event transport", () => {
|
||||
it("preserves remote authentication protocols and event query parameters", async () => {
|
||||
pluginWsUrl.mockResolvedValue({
|
||||
url: "wss://server/base/plugin-ws/ssh-terminal/terminal",
|
||||
protocols: ["jwt", "session-token"],
|
||||
});
|
||||
expect(
|
||||
await wsTargetForPath(
|
||||
"/plugin-ws/ssh-terminal/terminal?room=abc",
|
||||
"remote",
|
||||
),
|
||||
).toEqual({
|
||||
url: "wss://server/base/plugin-ws/ssh-terminal/terminal?room=abc",
|
||||
protocols: ["jwt", "session-token"],
|
||||
});
|
||||
expect(pluginWsUrl).toHaveBeenCalledWith("ssh-terminal", "/terminal", {
|
||||
origin: "remote",
|
||||
});
|
||||
expect(
|
||||
await wsTargetForPath("https://another-server/ws", "remote"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/// <reference types="vite/client" />
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import roomSource from "../../src/frontend/CollabRoomTab.tsx?raw";
|
||||
|
||||
// Run the room's real refresh callback across successive server snapshots.
|
||||
const source = ts.createSourceFile(
|
||||
"CollabRoomTab.tsx",
|
||||
roomSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let callback: string | undefined;
|
||||
function visit(node: ts.Node) {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
node.name.getText(source) === "refresh" &&
|
||||
node.initializer &&
|
||||
ts.isCallExpression(node.initializer)
|
||||
) {
|
||||
callback = node.initializer.arguments[0].getText(source);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
if (!callback) throw new Error("Missing room refresh callback");
|
||||
const refresh = new Function(
|
||||
"state",
|
||||
ts.transpileModule(
|
||||
`
|
||||
const { roomId, refreshSequence, presenterRef, stageKeyRef, draftRef,
|
||||
getCollabRoom, getCollabStage, setDetail, setLoadError, setDraft,
|
||||
setStage, getErrorMessage } = state;
|
||||
return (${callback})();
|
||||
`,
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
|
||||
).outputText,
|
||||
) as (state: ReturnType<typeof fixture>) => Promise<void>;
|
||||
|
||||
function snapshot(presenterUserId: string, shareId: string) {
|
||||
return {
|
||||
me: "alice",
|
||||
stage: { presenterUserId, shareId },
|
||||
controllerUserId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const state = {
|
||||
roomId: "room",
|
||||
refreshSequence: { current: 0 },
|
||||
presenterRef: { current: null as string | null },
|
||||
stageKeyRef: { current: null as string | null },
|
||||
draftRef: { current: null as object | null },
|
||||
getCollabRoom: vi.fn(async () => snapshot("alice", "alice-share")),
|
||||
getCollabStage: vi.fn(async () => ({ stage: { shareId: "bob-share" } })),
|
||||
setDetail: vi.fn(),
|
||||
setLoadError: vi.fn(),
|
||||
setDraft: vi.fn((draft: object | null) => {
|
||||
state.draftRef.current = draft;
|
||||
}),
|
||||
setStage: vi.fn(),
|
||||
getErrorMessage: String,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("meeting presenter takeover", () => {
|
||||
it("replaces the previous presenter's local session with the new stage", async () => {
|
||||
const state = fixture();
|
||||
state.draftRef.current = { protocol: "ssh", host: { id: 1 } };
|
||||
await refresh(state);
|
||||
state.getCollabRoom.mockResolvedValue(snapshot("bob", "bob-share"));
|
||||
await refresh(state);
|
||||
|
||||
expect(state.draftRef.current).toBeNull();
|
||||
expect(state.setStage).toHaveBeenCalledWith({ shareId: "bob-share" });
|
||||
expect(state.getCollabStage).toHaveBeenCalledWith("room");
|
||||
});
|
||||
|
||||
it("keeps a new connection being prepared to take over another presenter", async () => {
|
||||
const state = fixture();
|
||||
state.getCollabRoom.mockResolvedValue(snapshot("bob", "bob-share"));
|
||||
await refresh(state);
|
||||
const draft = { protocol: "ssh", host: { id: 2 } };
|
||||
state.draftRef.current = draft;
|
||||
await refresh(state);
|
||||
|
||||
expect(state.draftRef.current).toBe(draft);
|
||||
expect(state.setDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the local session during ordinary room refreshes", async () => {
|
||||
const state = fixture();
|
||||
const draft = { protocol: "ssh", host: { id: 1 } };
|
||||
state.draftRef.current = draft;
|
||||
await refresh(state);
|
||||
await refresh(state);
|
||||
|
||||
expect(state.draftRef.current).toBe(draft);
|
||||
expect(state.getCollabStage).not.toHaveBeenCalled();
|
||||
expect(state.setDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
guestViewUrl,
|
||||
sessionPublicUrl,
|
||||
} from "../../src/frontend/meeting-backend";
|
||||
|
||||
function desktop(url: string | null): TermixApp {
|
||||
return {
|
||||
desktop: { available: true, remoteServerUrl: async () => url },
|
||||
} as unknown as TermixApp;
|
||||
}
|
||||
|
||||
describe("session share links", () => {
|
||||
it("builds the link on the linked server for a remote session", async () => {
|
||||
const base = await sessionPublicUrl(
|
||||
desktop("https://termix.example/"),
|
||||
"remote",
|
||||
);
|
||||
expect(guestViewUrl(base, "shared", "tok")).toBe(
|
||||
"https://termix.example/?view=shared&token=tok",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives no link for a session on the desktop's own backend", async () => {
|
||||
expect(
|
||||
await sessionPublicUrl(desktop("https://termix.example/"), "local"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
guestViewUrl("file:///C:/app/index.html", "shared", "tok"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the page address in a browser", async () => {
|
||||
const app = { desktop: { available: false } } as unknown as TermixApp;
|
||||
expect(await sessionPublicUrl(app, undefined)).toBe(window.location.href);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user