feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

+1 -19
View File
@@ -161,7 +161,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-OJgSdz/tftu3bBUxP1DqbBkfBm/fs2VjcBuZm3txHlk='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-IaCkn7qYpeosdQvIY2TXZn11ghTwUsfkSNLXPkjBcSY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -528,24 +528,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/session-sharing(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/collab(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved # File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved
# to the file-manager plugin, served under /plugin-api/file-manager/. # to the file-manager plugin, served under /plugin-api/file-manager/.
+1 -19
View File
@@ -142,7 +142,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-OJgSdz/tftu3bBUxP1DqbBkfBm/fs2VjcBuZm3txHlk='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-IaCkn7qYpeosdQvIY2TXZn11ghTwUsfkSNLXPkjBcSY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -506,24 +506,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/session-sharing(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/collab(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved # File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved
# to the file-manager plugin, served under /plugin-api/file-manager/. # to the file-manager plugin, served under /plugin-api/file-manager/.
+6
View File
@@ -0,0 +1,6 @@
-- session_shares, session_share_participants, collab_rooms and
-- collab_room_members moved to the session-sharing plugin, whose first
-- migration renames them. Dropping them here would lose every share and
-- room, so this only stops drizzle tracking them. SELECT 1 because MySQL
-- rejects an empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -281,6 +281,13 @@
"when": 1790220944905, "when": 1790220944905,
"tag": "0039_condemned_shiva", "tag": "0039_condemned_shiva",
"breakpoints": true "breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1790224430728,
"tag": "0040_misty_skin",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,6 @@
-- session_shares, session_share_participants, collab_rooms and
-- collab_room_members moved to the session-sharing plugin, whose first
-- migration renames them. Dropping them here would lose every share and
-- room, so this only stops drizzle tracking them. SELECT 1 because MySQL
-- rejects an empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -288,6 +288,13 @@
"when": 1790220944274, "when": 1790220944274,
"tag": "0040_narrow_hellion", "tag": "0040_narrow_hellion",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1790224430003,
"tag": "0041_watery_zeigeist",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,6 @@
-- session_shares, session_share_participants, collab_rooms and
-- collab_room_members moved to the session-sharing plugin, whose first
-- migration renames them. Dropping them here would lose every share and
-- room, so this only stops drizzle tracking them. SELECT 1 because MySQL
-- rejects an empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -260,6 +260,13 @@
"when": 1790220943638, "when": 1790220943638,
"tag": "0036_classy_nighthawk", "tag": "0036_classy_nighthawk",
"breakpoints": true "breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1790224429343,
"tag": "0037_burly_enchantress",
"breakpoints": true
} }
] ]
} }
+28 -3
View File
@@ -7016,6 +7016,10 @@
"resolved": "plugins/serial", "resolved": "plugins/serial",
"link": true "link": true
}, },
"node_modules/@termix-plugin/session-sharing": {
"resolved": "plugins/session-sharing",
"link": true
},
"node_modules/@termix-plugin/snippets": { "node_modules/@termix-plugin/snippets": {
"resolved": "plugins/snippets", "resolved": "plugins/snippets",
"link": true "link": true
@@ -7028,6 +7032,10 @@
"resolved": "plugins/tailscale", "resolved": "plugins/tailscale",
"link": true "link": true
}, },
"node_modules/@termix-plugin/tmux-monitor": {
"resolved": "plugins/tmux-monitor",
"link": true
},
"node_modules/@termix-plugin/tunnels": { "node_modules/@termix-plugin/tunnels": {
"resolved": "plugins/tunnels", "resolved": "plugins/tunnels",
"link": true "link": true
@@ -8132,7 +8140,6 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@xterm/addon-search": { "node_modules/@xterm/addon-search": {
@@ -8160,7 +8167,6 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"addons/*" "addons/*"
@@ -15924,7 +15930,6 @@
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.12.tgz", "resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.12.tgz",
"integrity": "sha512-8uXBzVF4vP6r2NABxt/9heH1Y5Vx2v2hFrAk1kre+HyY3CjM6Prbc8ViqBRia6EBzI57IFBWOicYV1/k05JBtw==", "integrity": "sha512-8uXBzVF4vP6r2NABxt/9heH1Y5Vx2v2hFrAk1kre+HyY3CjM6Prbc8ViqBRia6EBzI57IFBWOicYV1/k05JBtw==",
"dev": true,
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@xterm/xterm": "^5.5.0" "@xterm/xterm": "^5.5.0"
@@ -18609,6 +18614,19 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/session-sharing": {
"name": "@termix-plugin/session-sharing",
"version": "1.0.0",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react-xtermjs": "^1.0.10",
"redis": "^6.2.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/snippets": { "plugins/snippets": {
"name": "@termix-plugin/snippets", "name": "@termix-plugin/snippets",
"version": "1.0.0", "version": "1.0.0",
@@ -18630,6 +18648,13 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/tmux-monitor": {
"name": "@termix-plugin/tmux-monitor",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/tunnels": { "plugins/tunnels": {
"name": "@termix-plugin/tunnels", "name": "@termix-plugin/tunnels",
"version": "1.0.0", "version": "1.0.0",
+53 -10
View File
@@ -367,6 +367,14 @@ Anonymous shared-session and collab pages have no session. They load only
plugins whose manifest sets `contributes.guest: true`, from the public plugins whose manifest sets `contributes.guest: true`, from the public
`GET /plugins/public`, and `app.guest` is true there. `GET /plugins/public`, and `app.guest` is true there.
**B12** made those pages generic. A guest plugin lists the `?view=` names it
serves anonymously in `contributes.guestViews` (which needs `guest: true`),
and `/plugins/public` returns them. `src/main.tsx` asks for that list when a
URL carries `?view=`: a guest view starts the guest runtime and renders the
tab type whose `standalone`/`standaloneViews` claims it, anything else goes
through the signed-in full-screen gate as before. The shell no longer knows
`?view=shared` or `?view=collab-guest`; session-sharing declares both.
The login screen runs a smaller pass before anyone signs in: it activates only The login screen runs a smaller pass before anyone signs in: it activates only
enabled plugins that contribute `loginMethods` or `secondFactors`, listed by enabled plugins that contribute `loginMethods` or `secondFactors`, listed by
the public `GET /plugins/public-manifest`. They stay active after login, and the public `GET /plugins/public-manifest`. They stay active after login, and
@@ -426,7 +434,7 @@ boundary and Suspense.
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) | | `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL | | `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) | | `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces | | `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup | | `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
@@ -445,7 +453,14 @@ Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot, renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`, where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor` `onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
and `session.remoteDisplay`. `host-metrics.managers` (component slot, and `shell.overlay`. **B12** added `shell.overlay`, a component slot the
shell renders once at its root for always-mounted plugin UI: session-sharing
keeps its share dialog and its room-invite watcher there.
`session.remoteDisplay` moved to session-sharing, which declares it and draws
whatever remote desktop contributes to it in rooms and share links.
`remote-desktop.toolbar` (button slot, **B12**) is remote desktop's own,
invoked with `{ hostId, sessionId, protocol, tabInstanceId }`.
`host-metrics.managers` (component slot,
**B3**) is host-metrics's own: tailscale contributes its manager card there **B3**) is host-metrics's own: tailscale contributes its manager card there
instead of host-metrics knowing tailscale exists, and host-metrics works with instead of host-metrics knowing tailscale exists, and host-metrics works with
or without tailscale enabled. Cross-plugin frontend calls go through actions: or without tailscale enabled. Cross-plugin frontend calls go through actions:
@@ -474,7 +489,7 @@ tools act on the last one focused; `PanelProps.targetTab` hands a panel that
tab), `ownBackground` (the shell leaves the frame transparent) and tab), `ownBackground` (the shell leaves the frame transparent) and
`multiInstance` (each open is a new tab). `TabHandle` documents what a session `multiInstance` (each open is a new tab). `TabHandle` documents what a session
tab puts on `handleRef`: focus, reconnect, disconnect and, for sharing, tab puts on `handleRef`: focus, reconnect, disconnect and, for sharing,
`getShareTarget()`, which the shell's own share dialog uses. These replaced `getShareTarget()` for sharing. These replaced
every check of the `terminal` and `local-terminal` tab types in the shell. every check of the `terminal` and `local-terminal` tab types in the shell.
**B2** added a live-terminal-session surface, for a plugin that needs to push **B2** added a live-terminal-session surface, for a plugin that needs to push
@@ -598,13 +613,35 @@ who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
the authority there. the authority there.
**Core consuming a plugin service.** Session sharing, collab and the open **Core consuming a plugin service.** The open tabs route is still core and
tabs route are still core and reach live terminal sessions through lists the caller's own terminal sessions through
`getServiceImplementation(service, range)` in `service-registry.ts` `getServiceImplementation(service, range, name?)` in `service-registry.ts`
(`hosts/live-terminal-sessions.ts` wraps it). Core already authorized the (`hosts/live-terminal-sessions.ts` wraps it). Core already authorized the
request, so this skips the per-call check and returns the same object request, so this skips the per-call check and returns the same object
plugins get, or undefined while no compatible provider runs. plugins get, or undefined while no compatible provider runs.
**Named providers (B12).** A service several plugins provide side by side is
keyed by a provider name. The manifest lists the names in
`provides[].names`, `ctx.services.provide(service, impl, { name })` refuses
any other, `ctx.services.get(service, { provider })` reaches one, and
`ctx.services.providers(service)` lists the running ones. A requirement is
satisfied by any compatible provider. `sessions.live` is the first: keyed by
session type, ssh-terminal provides `ssh` and remote desktop provides `rdp`,
`vnc` and `telnet` (B14), with `createViewerToken(sessionId, readOnly)` for
a viewer. A consumer with no actor, such as a public guest route, calls it
through `ctx.asUser(<share owner>)`.
**Session sharing (B12)** is the session-sharing plugin. It provides
`sessions.sharing` (member joins, room events) and `sessions.sharing.guests`,
adopts `session_shares`, `session_share_participants`, `collab_rooms` and
`collab_room_members`, and reaches sessions only through `sessions.live`. Its
two public routes, `/resolve/:linkToken` and `/guest/:token`, check the token
themselves and are rate limited per IP. The share button is a contribution to
`terminal.toolbar` and `remote-desktop.toolbar`; the tab bar's share entry and
the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
sessions shared with a user from the `sessions.sharedWithMe` action.
### 10. Lifecycle ### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app` `activate(ctx)` creates all state. Everything registered through `ctx` or `app`
@@ -638,7 +675,7 @@ not care about the gates, `createTestDb(pluginDir)` for a real database, and
`renderWithApp(plugin, options)` for frontend tests. `renderWithApp(plugin, options)` for frontend tests.
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub `createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
`users`, `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs `users` (with `is_admin`, **B12**), `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
`before` (seed a legacy table there to test an adoption), then applies the `before` (seed a legacy table there to test an adoption), then applies the
plugin's own `migrations/sqlite` with the runner's splitter. `ctx.db.refs()` plugin's own `migrations/sqlite` with the runner's splitter. `ctx.db.refs()`
against this database returns the same four, built by `buildRefTable`, which against this database returns the same four, built by `buildRefTable`, which
@@ -653,7 +690,10 @@ against the real schema's property names works unchanged in a test. Pass its
and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess` and `hosts` (`PluginHostSummary[]`, what `ctx.hosts.list/get/checkAccess`
serve - **B8** added this option, since it existed on `createFakeContext` serve - **B8** added this option, since it existed on `createFakeContext`
already but was not forwarded), and returns `setActor(userId)` for a test already but was not forwarded), and returns `setActor(userId)` for a test
middleware and `services` for what the plugin provided. **B8** also gave middleware and `services` for what the plugin provided. **B12** added a
`services` option seeding other plugins' services for a consumer's test,
keyed `<service>` or `<service>#<provider>`, and the doubles now answer a
missing service with an empty handle, like the runtime, instead of throwing. **B8** also gave
`ctx.registry` on both doubles a real in-memory backing (it was a no-op stub `ctx.registry` on both doubles a real in-memory backing (it was a no-op stub
before), so `provide`/`consume`/`revoke` round-trip the way the real registry before), so `provide`/`consume`/`revoke` round-trip the way the real registry
does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example. does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example.
@@ -1068,7 +1108,10 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
`contributes.panels` and `contributes.dashboardCards` (each `{ id, titleKey, `contributes.panels` and `contributes.dashboardCards` (each `{ id, titleKey,
icon? }`) declare the views a plugin owns besides its tabs; the app object icon? }`) declare the views a plugin owns besides its tabs; the app object
refuses to register a view the manifest does not declare. `contributes.guest` refuses to register a view the manifest does not declare. `contributes.guest`
opts a plugin into anonymous guest pages. Action contributions and slots opts a plugin into anonymous guest pages, and `contributes.guestViews` names
the `?view=` pages it serves there. `provides[].names` lists the named
providers a plugin registers for a service keyed by provider (see Cross-plugin
use). Action contributions and slots
accept the kinds `button` and `component`. accept the kinds `button` and `component`.
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A `contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
@@ -1148,7 +1191,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | | `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | | `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** | | `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** | | `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | | `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** | | `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** | | `ctx.asUser(userId, fn)` | none, always audited | **A1** |
+32 -15
View File
@@ -144,21 +144,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
to ask Electron's main process for something, extend to ask Electron's main process for something, extend
`BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel. `BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel.
No owner needed unless a second caller appears. No owner needed unless a second caller appears.
- **B9 (ssh-terminal):** session sharing has no provider yet. The terminal
consumes `sessions.sharing` v1 (member joins through `authorizeJoin` and
`recordJoin`, collab room events through `subscribeRoom`/`unsubscribeRoom`)
and reads guest link resolution from `ctx.registry` as
`sessions.sharing.guests` (`resolve`, `recordJoin`), all typed in
`plugins/ssh-terminal/src/backend/services.ts`. Until something provides
them, share-link and room guests are refused and in-app joins fail. Core's
session-sharing, collab and open-tabs code reach the terminal through
`sessions.live` already. Owner: B12, which provides both from the
session-sharing plugin (the lookups are the old code in
`hosts/session-sharing` and `hosts/collab`: share repo, room repo, guest
rate limit, `canJoinRoomStageShare`, `session_sharing_globally_enabled`,
host `allowSessionSharing`, `getAuditUsername` for labels, `collabRoomHub`).
B12 also moves `eventsWsPath` in `collab/routes.ts` and the share dialog the
shell opens from a tab's `getShareTarget()`.
- **B9 (ssh-terminal), for D2:** the terminal socket is a public route with - **B9 (ssh-terminal), for D2:** the terminal socket is a public route with
`optionalAuth`, because share-link and room guests authenticate inside the `optionalAuth`, because share-link and room guests authenticate inside the
handler with their token. Guest auth itself is unchanged; review it with the handler with their token. Guest auth itself is unchanged; review it with the
@@ -270,6 +255,34 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
packaging, or a documented "avoid native code" rule for community plugins). packaging, or a documented "avoid native code" rule for community plugins).
- **B12 (session-sharing):** the remote desktop side of sharing has no
provider yet. Session sharing reaches rdp, vnc and telnet sessions through
`sessions.live` named providers `rdp`, `vnc` and `telnet`, each with
`getSession(sessionId)` (id, userId as the session's owner, hostId,
hostName, isConnected, createdAt, tabInstanceId) and
`createViewerToken(sessionId, readOnly)`, typed in
`plugins/session-sharing/src/backend/live.ts`. Until remote-desktop provides
them, sharing or presenting an RDP/VNC/Telnet session is refused and those
links resolve 404. B14 also drops `createJoinToken`/`getSessionInfo` from
its `remote-desktop.sessions` registry entry, which core no longer reads.
Owner: B14.
- **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data`
column. The copy into the plugin's host setting shipped
(`session-sharing-settings-migration.ts`) and nothing in core reads or
writes it any more; drop it in lockstep (`schema.ts`, `db/index.ts`, a
drizzle migration per dialect, `schema:generate`). Owner: D0.
- **B12 (session-sharing):** the legacy `session_sharing_globally_enabled`
settings row is left in place for a release; nothing reads it. Delete it in
3.0.0. Owner: D0 to decide.
- **B12 (session-sharing):** room audit lines go through `ctx.audit.record`,
which records the actor but not the request's IP address and user agent the
old `logAudit` calls carried. Adding those to `ctx.audit.record` would
restore them. Owner: D1.
- **B12 (session-sharing):** the share button lives in the terminal and remote
desktop toolbars now; the tab bar's share button is gone, so a host with the
terminal toolbar switched off has no share button. A generic tab-menu slot
would bring it back if that matters. Owner: D1, if wanted.
## Manual checks after 2.9.0 ## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase - SSH terminal: password auth, key auth (and an encrypted key's passphrase
@@ -278,3 +291,7 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
page reload (session reattach), and the local terminal in the desktop app. page reload (session reattach), and the local terminal in the desktop app.
- Serial: the Electron backend path against a real device path/COM port, and - Serial: the Electron backend path against a real device path/COM port, and
the Web Serial browser picker in Chrome or Edge. the Web Serial browser picker in Chrome or Edge.
- Session sharing: a link share (read-only and read-write) opened in a private
window, a user share joined from Active Connections, revoke and "end for
everyone", and a collab room with two members, control hand-off, a guest
link, rotate and end.
@@ -162,6 +162,12 @@
"permission": { "permission": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
},
"names": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
} }
} }
} }
@@ -265,6 +271,12 @@
} }
}, },
"guest": { "type": "boolean" }, "guest": { "type": "boolean" },
"guestViews": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
},
"auth": { "auth": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
+17 -2
View File
@@ -179,8 +179,23 @@ export interface PluginRegistry {
} }
export interface PluginServices { export interface PluginServices {
provide: <T extends object>(service: string, implementation: T) => void; /**
get: <T extends object>(service: string, options?: { userId?: string }) => T; * `name` registers one of several providers of the same service, for a
* service keyed by something the caller picks (sessions.live by session
* type). It must be listed in the manifest's `provides[].names`.
*/
provide: <T extends object>(
service: string,
implementation: T,
options?: { name?: string },
) => void;
/** `provider` picks a named provider; omitted, the unnamed one. */
get: <T extends object>(
service: string,
options?: { userId?: string; provider?: string },
) => T;
/** Names of the providers running now ("" for an unnamed one). */
providers: (service: string) => string[];
} }
export interface PluginSecrets { export interface PluginSecrets {
+1 -17
View File
@@ -96,7 +96,7 @@ export interface ShellApi {
export interface TabsApi extends Pick< export interface TabsApi extends Pick<
ShellApi, ShellApi,
"openTab" | "openSingletonTab" | "closeTab" "openTab" | "openSingletonTab" | "closeTab" | "openRailView"
> { > {
/** The current tabs and split layout, or null before the shell mounts. */ /** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null; getLayout: () => ShellLayout | null;
@@ -187,16 +187,6 @@ export interface TabHandle {
paste?: (text: string) => void; paste?: (text: string) => void;
refresh?: () => void; refresh?: () => void;
notifyResize?: () => void; notifyResize?: () => void;
/** A tab that shows its own share dialog. */
canShare?: () => boolean;
openShareModal?: () => void;
/** A tab whose session the shell's share dialog can share. */
getShareTarget?: () => {
hostId: number;
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
} | null;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -232,12 +222,6 @@ export interface TabOptions {
singleton?: boolean; singleton?: boolean;
/** A live session: asks before closing and can be refreshed. */ /** A live session: asks before closing and can be refreshed. */
session?: boolean; session?: boolean;
/**
* Whether a session tab offers "Share session". Default true. A plugin
* whose ref does not implement session sharing (a local device, not a
* remote connection another user could join) sets this false.
*/
shareable?: boolean;
/** Restorable without a host. */ /** Restorable without a host. */
hostless?: boolean; hostless?: boolean;
/** Decides whether a saved tab for this host may be restored. */ /** Decides whether a saved tab for this host may be restored. */
+45 -1
View File
@@ -145,6 +145,12 @@ export interface PluginServiceProvide {
version: string; version: string;
/** The role permission a calling user needs. */ /** The role permission a calling user needs. */
permission: string; permission: string;
/**
* Named providers this plugin registers, for a service several plugins
* provide side by side (sessions.live, keyed by session type). Omitted, the
* plugin provides it once, unnamed.
*/
names?: string[];
} }
export interface PluginServiceRequire { export interface PluginServiceRequire {
@@ -256,6 +262,11 @@ export interface PluginContributions {
* collab links), where it is activated with app.guest set. * collab links), where it is activated with app.guest set.
*/ */
guest?: boolean; guest?: boolean;
/**
* `?view=` names an anonymous guest page may open. Each must be served by a
* tab's `standalone` component. Needs `guest: true`.
*/
guestViews?: string[];
actions?: PluginActionContribution[]; actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[]; actionSlots?: PluginActionSlot[];
permissions?: PluginPermissionContribution[]; permissions?: PluginPermissionContribution[];
@@ -348,6 +359,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"panels", "panels",
"dashboardCards", "dashboardCards",
"guest", "guest",
"guestViews",
"actions", "actions",
"actionSlots", "actionSlots",
"permissions", "permissions",
@@ -598,7 +610,12 @@ function validateProvides(provides: unknown, errors: string[]): void {
errors.push(`${where} must be an object`); errors.push(`${where} must be an object`);
return; return;
} }
rejectUnknown(raw, ["service", "version", "permission"], where, errors); rejectUnknown(
raw,
["service", "version", "permission", "names"],
where,
errors,
);
if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) { if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) {
errors.push(`${where}.service must be a dotted lowercase name`); errors.push(`${where}.service must be a dotted lowercase name`);
@@ -613,9 +630,30 @@ function validateProvides(provides: unknown, errors: string[]): void {
errors.push(`${where}.version must be valid semver`); errors.push(`${where}.version must be valid semver`);
} }
requireString(raw.permission, `${where}.permission`, errors); requireString(raw.permission, `${where}.permission`, errors);
if (raw.names !== undefined) {
validateNameList(raw.names, `${where}.names`, errors);
}
}); });
} }
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
return;
}
const seen = new Set<string>();
for (const name of value) {
if (typeof name !== "string" || !PROVIDER_NAME_PATTERN.test(name)) {
errors.push(`${where} entries must be lowercase names`);
continue;
}
if (seen.has(name)) errors.push(`${where} duplicates "${name}"`);
seen.add(name);
}
}
function validateRequires(requires: unknown, errors: string[]): void { function validateRequires(requires: unknown, errors: string[]): void {
if (requires === undefined) return; if (requires === undefined) return;
if (!Array.isArray(requires)) { if (!Array.isArray(requires)) {
@@ -721,6 +759,12 @@ function validateContributes(
) { ) {
errors.push('Field "contributes.guest" must be a boolean'); errors.push('Field "contributes.guest" must be a boolean');
} }
if (contributes.guestViews !== undefined) {
validateNameList(contributes.guestViews, "contributes.guestViews", errors);
if (contributes.guest !== true) {
errors.push('Field "contributes.guestViews" needs "contributes.guest"');
}
}
validatePermissions(contributes.permissions, pluginId, errors); validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors); validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors); validateActionSlots(contributes.actionSlots, errors);
+6 -4
View File
@@ -104,15 +104,17 @@ export function buildTable(
*/ */
export function buildRefTable( export function buildRefTable(
name: string, name: string,
columns: Record<string, "text" | "integer">, columns: Record<string, "text" | "integer" | "boolean">,
): unknown { ): unknown {
const built: Record<string, unknown> = {}; const built: Record<string, unknown> = {};
for (const [property, type] of Object.entries(columns)) { for (const [property, type] of Object.entries(columns)) {
const columnSqlName = columnName(property); const columnSqlName = columnName(property);
built[property] = built[property] =
type === "integer" type === "boolean"
? sqliteInteger(columnSqlName) ? sqliteInteger(columnSqlName, { mode: "boolean" })
: sqliteText(columnSqlName); : type === "integer"
? sqliteInteger(columnSqlName)
: sqliteText(columnSqlName);
} }
return sqliteTable(name, built as never); return sqliteTable(name, built as never);
} }
+36 -15
View File
@@ -57,6 +57,11 @@ export interface FakeContextOptions {
* when omitted every check passes, so tests that do not care stay simple. * when omitted every check passes, so tests that do not care stay simple.
*/ */
permissions?: string[]; permissions?: string[];
/**
* Services other plugins provide, for testing a consumer. Keyed by service
* name, or "<service>#<provider>" for a named provider.
*/
services?: Record<string, object>;
/** Hosts ctx.hosts.list/get/checkAccess answer with. */ /** Hosts ctx.hosts.list/get/checkAccess answer with. */
hosts?: PluginHostSummary[]; hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */ /** Users and roles ctx.hosts.listUsers/listRoles answer with. */
@@ -132,7 +137,7 @@ export interface FakePluginContext {
}>; }>;
/** Changes the acting user, as core's request middleware would. */ /** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void; setActor: (userId: string | undefined) => void;
/** Implementations provided through ctx.services.provide, by service name. */ /** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>; services: Map<string, object>;
} }
@@ -210,7 +215,11 @@ export function createFakeContext(
); );
const hostRecordsById = new Map<number, PluginHostRecord>(); const hostRecordsById = new Map<number, PluginHostRecord>();
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1; let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
const services = new Map<string, object>(); const services = new Map<string, object>(
Object.entries(options.services ?? {}),
);
const serviceKey = (service: string, name?: string) =>
name ? `${service}#${name}` : service;
const registryProviders = new Map<string, unknown>(); const registryProviders = new Map<string, unknown>();
const auth: FakeAuthRegistrations = { const auth: FakeAuthRegistrations = {
sshAuthProviders: [], sshAuthProviders: [],
@@ -349,18 +358,23 @@ export function createFakeContext(
}, },
services: { services: {
provide: (service, implementation) => { provide: (service, implementation, provideOptions) => {
services.set(service, implementation); services.set(serviceKey(service, provideOptions?.name), implementation);
},
// Only services this same context provided. A test of a consumer
// should provide a stub first.
get: (service) => {
const implementation = services.get(service);
if (!implementation) {
throw new Error(`No service "${service}" was provided in this test`);
}
return implementation as never;
}, },
// What this context provided or the test seeded through `services`.
// Like the real handle, a missing provider is an empty object, which a
// consumer's `"method" in handle` probe reads as absent.
get: (service, getOptions) =>
(services.get(serviceKey(service, getOptions?.provider)) ??
{}) as never,
providers: (service) =>
[...services.keys()].flatMap((key) =>
key === service
? [""]
: key.startsWith(`${service}#`)
? [key.slice(service.length + 1)]
: [],
),
}, },
secrets: { secrets: {
@@ -680,6 +694,8 @@ export interface MockContextOptions {
permissions?: string[]; permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */ /** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
hosts?: PluginHostSummary[]; hosts?: PluginHostSummary[];
/** Other plugins' services. See FakeContextOptions. */
services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */ /** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[]; sshHosts?: PluginSshHost[];
} }
@@ -717,6 +733,7 @@ export function createMockCtx(
permissions: options.permissions, permissions: options.permissions,
hosts: options.hosts, hosts: options.hosts,
sshHosts: options.sshHosts, sshHosts: options.sshHosts,
services: options.services,
manifest: { manifest: {
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
...options.manifest, ...options.manifest,
@@ -1018,7 +1035,7 @@ export async function createTestDb(
const sqlite = new Database(":memory:") as TestSqlite; const sqlite = new Database(":memory:") as TestSqlite;
sqlite.exec("PRAGMA foreign_keys = ON"); sqlite.exec("PRAGMA foreign_keys = ON");
sqlite.exec( sqlite.exec(
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')", "CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '', is_admin INTEGER NOT NULL DEFAULT 0)",
); );
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)"); sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
sqlite.exec( sqlite.exec(
@@ -1059,7 +1076,11 @@ export async function createTestDb(
if (!options.skipMigrations) await migrate(); if (!options.skipMigrations) await migrate();
const refs = { const refs = {
users: builder.buildRefTable("users", { id: "text", username: "text" }), users: builder.buildRefTable("users", {
id: "text",
username: "text",
isAdmin: "boolean",
}),
hosts: builder.buildRefTable("ssh_data", { id: "integer" }), hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
roles: builder.buildRefTable("roles", { roles: builder.buildRefTable("roles", {
id: "integer", id: "integer",
+2 -2
View File
@@ -12,8 +12,8 @@ import {
* exist for anyone, regardless of what any user has enabled. It defaults to * exist for anyone, regardless of what any user has enabled. It defaults to
* false so upgrading an existing install turns nothing on by surprise. * false so upgrading an existing install turns nothing on by surprise.
* *
* Mirrors the shape of isSharingEnabledForHost in the session-sharing routes, * Mirrors session sharing's own check, where the global also wins over the
* where the global also wins over the per-entity setting. * per-host setting.
*/ */
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled"; export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
+6
View File
@@ -60,6 +60,12 @@
"editorTab": "telnet" "editorTab": "telnet"
} }
], ],
"actionSlots": [
{
"id": "remote-desktop.toolbar",
"accepts": ["button"]
}
],
"guest": true "guest": true
}, },
"locales": "locales", "locales": "locales",
@@ -45,7 +45,6 @@ import {
ConnectionLogProvider, ConnectionLogProvider,
useConnectionLog, useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx"; } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { resolveConnectionDefaults } from "@/lib/connection-defaults"; import { resolveConnectionDefaults } from "@/lib/connection-defaults";
@@ -68,8 +67,6 @@ export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
export interface GuacamoleAppHandle { export interface GuacamoleAppHandle {
disconnect: () => void; disconnect: () => void;
isConnected: () => boolean; isConnected: () => boolean;
openShareModal: () => void;
canShare: () => boolean;
/** Reconnects, from the tab bar's refresh. */ /** Reconnects, from the tab bar's refresh. */
refresh: () => void; refresh: () => void;
} }
@@ -202,7 +199,6 @@ const GuacamoleAppInner = React.forwardRef<
id: string; id: string;
origin: ConnectionOrigin; origin: ConnectionOrigin;
} | null>(null); } | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [isDisplayReady, setIsDisplayReady] = useState(false); const [isDisplayReady, setIsDisplayReady] = useState(false);
@@ -299,8 +295,6 @@ const GuacamoleAppInner = React.forwardRef<
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(), disconnect: () => displayRef.current?.disconnect(),
isConnected: () => displayRef.current?.isConnected() === true, isConnected: () => displayRef.current?.isConnected() === true,
openShareModal: () => setShareModalOpen(true),
canShare: () => guacamoleConnectionId !== null,
refresh: () => reconnectRef.current(), refresh: () => reconnectRef.current(),
})); }));
@@ -649,16 +643,16 @@ const GuacamoleAppInner = React.forwardRef<
onTouchModeChange={setTouchMode} onTouchModeChange={setTouchMode}
zoom={displayZoom} zoom={displayZoom}
onHide={() => setToolbarHidden(true)} onHide={() => setToolbarHidden(true)}
/> slotContext={
)} typeof hostId === "number"
{shareModalOpen && guacamoleConnectionId && ( ? {
<ShareSessionModal hostId,
open={shareModalOpen} sessionId: guacamoleConnectionId,
onClose={() => setShareModalOpen(false)} protocol: resolvedProtocol,
hostId={hostId} tabInstanceId: tabId,
sessionId={guacamoleConnectionId} }
protocol={resolvedProtocol} : undefined
tabInstanceId={tabId} }
/> />
)} )}
</div> </div>
@@ -32,6 +32,7 @@ import type {
GuacamoleTouchMode, GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx"; } from "./GuacamoleDisplay.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ActionSlot } from "@termix/plugin-sdk/ui";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
detectRuntimeMetaKeyFamily, detectRuntimeMetaKeyFamily,
@@ -50,6 +51,19 @@ interface GuacamoleToolbarProps {
zoom?: number; zoom?: number;
onHide?: () => void; onHide?: () => void;
metaKeyFamily?: MetaKeyFamily; metaKeyFamily?: MetaKeyFamily;
/** Handed to "remote-desktop.toolbar" actions (the share button). */
slotContext?: RemoteDesktopToolbarContext;
}
export const REMOTE_DESKTOP_TOOLBAR_SLOT = "remote-desktop.toolbar";
/** What a "remote-desktop.toolbar" action is invoked with. */
export interface RemoteDesktopToolbarContext {
hostId: number;
/** guacd's connection id, once the session is up. */
sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string;
} }
const MODIFIER_KEYSYMS = { const MODIFIER_KEYSYMS = {
@@ -140,6 +154,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
zoom = 1, zoom = 1,
onHide, onHide,
metaKeyFamily, metaKeyFamily,
slotContext,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const meta = metaKeyLabels( const meta = metaKeyLabels(
@@ -574,6 +589,16 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</div> </div>
</div> </div>
{slotContext && (
<ActionSlot
slotId={REMOTE_DESKTOP_TOOLBAR_SLOT}
className={cn(BTN_ICON)}
hideLabels
when={{ ...slotContext }}
context={() => [slotContext]}
/>
)}
{/* Collapse */} {/* Collapse */}
<div className="w-px h-5 bg-border mx-0.5 shrink-0" /> <div className="w-px h-5 bg-border mx-0.5 shrink-0" />
<Tooltip> <Tooltip>
@@ -27,6 +27,7 @@ import {
} from "./HostEditorGuacamoleTabs"; } from "./HostEditorGuacamoleTabs";
import { getGuacamoleTokenFromHost } from "./guacamole-api"; import { getGuacamoleTokenFromHost } from "./guacamole-api";
import { quickConnectGuacHost } from "./quick-connect-guac-host"; import { quickConnectGuacHost } from "./quick-connect-guac-host";
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
type Protocol = "rdp" | "vnc" | "telnet"; type Protocol = "rdp" | "vnc" | "telnet";
@@ -220,6 +221,12 @@ export function activate(app: TermixApp): void {
}); });
if (app.guest) return; if (app.guest) return;
// The share button lives here, contributed by session sharing.
app.declareActionSlot({
id: REMOTE_DESKTOP_TOOLBAR_SLOT,
accepts: ["button"],
});
// Mints a token for presenting a host in a collab room. // Mints a token for presenting a host in a collab room.
app.registerAction( app.registerAction(
"session.remoteDisplay.token", "session.remoteDisplay.token",
@@ -4,7 +4,8 @@ import { describe, expect, it, vi } from "vitest";
import { GuacamoleToolbar } from "../../src/frontend/GuacamoleToolbar.js"; import { GuacamoleToolbar } from "../../src/frontend/GuacamoleToolbar.js";
import type { GuacamoleDisplayHandle } from "../../src/frontend/GuacamoleDisplay.js"; import type { GuacamoleDisplayHandle } from "../../src/frontend/GuacamoleDisplay.js";
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key }), useTranslation: () => ({ t: (key: string) => key }),
})); }));
-3
View File
@@ -73,9 +73,6 @@ export function activate(app: TermixApp): void {
icon: Usb, icon: Usb,
titleKey: "nav.serial", titleKey: "nav.serial",
session: true, session: true,
// A physical device attached to this machine, not a remote connection
// another user could join.
shareable: false,
// Reconnecting to a COM port automatically after a restart is not a // Reconnecting to a COM port automatically after a restart is not a
// workflow anyone asked for, and there's no host to restore against // workflow anyone asked for, and there's no host to restore against
// anyway, so a serial tab is a local, ephemeral session like the // anyway, so a serial tab is a local, ephemeral session like the
+20
View File
@@ -0,0 +1,20 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Session sharing and collaboration rooms moved out of core and now serve
under `/plugin-api/session-sharing`. The old `/session-sharing` and
`/collab` routes are gone; guest links (`?view=shared` and
`?view=collab-guest`) keep working.
- Owns its data: core's `session_shares`, `session_share_participants`,
`collab_rooms` and `collab_room_members` tables are renamed into this
plugin on first activation.
- The instance-wide sharing switch and each host's sharing switch moved into
this plugin's admin and host settings.
- New `session-sharing.use` permission, given to the admin and user roles.
- Provides `sessions.sharing` and the `sessions.sharing.guests` registry entry,
which the terminal uses for member and guest joins.
- The share button moved from the tab bar into the terminal and remote
desktop toolbars.
+31
View File
@@ -0,0 +1,31 @@
# Session Sharing
Share a live terminal or remote desktop session by link or with another user,
and present sessions to a group in collaboration rooms.
Bundled with Termix and enabled by default. It imports only
`@termix/plugin-sdk`.
## What it does
- **Shares**: a link for anonymous guests, or a grant for one user, read-only
or read-write, with an expiry. The share button sits in the terminal and
remote desktop toolbars.
- **Rooms**: members watch one stage, the live session the presenter shows.
The presenter or host can hand input control to a member, and a room can
have an anonymous guest link.
- **`sessions.sharing`**: member joins and room events for the terminal
socket. Guest joins go through `sessions.sharing.guests` on `ctx.registry`,
because a guest has no user for a service's permission check.
It reaches live sessions only through the `sessions.live` service, keyed by
session type: ssh-terminal provides `ssh`, remote desktop provides `rdp`,
`vnc` and `telnet`.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx), routes, the room hub, the services
src/frontend/ share dialog, guest views, rooms panel and tab
migrations/ one .sql per dialect, generated from src/backend/tables.ts
tests/ backend (node) and frontend (jsdom)
+160
View File
@@ -0,0 +1,160 @@
{
"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",
"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}})",
"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.",
"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"
},
"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."
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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",
"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}})",
"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.",
"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"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "تم إنشاء رابط المشاركة",
"linkCopied": "تم نسخ الرابط إلى الحافظة",
"copyLink": "نسخ الوصلة",
"shareCreated": "تمت مشاركة الجلسة. ستظهر في علامة تبويب \"الاتصالات\" الخاصة بهم.",
"shareFailed": "فشل إنشاء المشاركة",
"userLacksHostAccess": "لا يملك هذا المستخدم صلاحية الوصول إلى هذا المضيف بعد. شارك المضيف معه أولاً، أو استخدم رابطاً بدلاً من ذلك.",
"activeShares": "الأسهم النشطة",
"noActiveShares": "لا توجد أسهم نشطة لهذه الجلسة",
"revoke": "إلغاء",
"revokeConfirmTitle": "إلغاء هذه الحصة؟",
"revokeConfirmDescription": "أي شخص يستخدم هذا الملف المشترك سيفقد الوصول إليه فوراً.",
"revoked": "تم إلغاء الحصة",
"revokeFailed": "فشل في إلغاء المشاركة",
"joinCount": "{{count}} انضم",
"joinCount_other": "{{count}} ينضم",
"expiresAt": "تاريخ الانتهاء {{date}}",
"linkShareBadge": "وصلة",
"userShareBadge": "المستخدم: {{username}}",
"loadSharesFailed": "فشل تحميل المشاركات النشطة",
"customHoursPlaceholder": "عدد الساعات حتى انتهاء صلاحية الوصول"
},
"collab": {
"createRoom": "غرف جديدة",
"roomName": "اسم الغرفة",
"persistentRoom": "غرفة مستمرة",
"persistentRoomHint": "تبقى الغرف الدائمة مدرجة بعد انتهاء الاجتماع ويمكن إعادة استخدامها.",
"noRooms": "لا توجد غرف اجتماعات بعد. أنشئ غرفة لبدء اجتماع.",
"openRoom": "يفتح",
"endRoom": "انتهاء الاجتماع",
"leaveRoom": "يترك",
"deleteMember": "يزيل",
"removeMember": "إزالة {{name}}",
"removeMemberTitle": "هل تريد حذف هذا العضو؟",
"removeMemberDescription": "سيفقد المستخدم {{name}} إمكانية الوصول إلى هذه الغرفة فورًا. وسيتم فصل أي برنامج عرض SSH نشط.",
"invite": "يدعو",
"inviteTitle": "دعوة الأعضاء",
"members": "أعضاء",
"membersPanel": "أعضاء الغرفة وطلبات التحكم",
"membersWithCount": "الأعضاء ({{count}})",
"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": "انتهت صلاحية علامة تبويب هذا الاجتماع. أعد فتح الغرفة من لوحة الاجتماعات.",
"requestControl": "التحكم في الطلبات",
"cancelControlRequest": "إلغاء الطلب",
"controlQueue": "طلبات التحكم",
"noControlRequests": "لا توجد طلبات معلقة.",
"grantControlTo": "قم بمنح التحكم إلى {{name}}",
"dismissControlRequest": "رفض طلب التحكم {{name}}",
"releaseControl": "التحكم في الإصدار",
"grantControl": "امنح السيطرة",
"revokeControl": "استعد السيطرة",
"controlRequestedBy": "طلب {{name}} التحكم",
"grant": "منحة",
"controlBadge": "مسيطر",
"youArePresenting": "أنت تعرض المحتوى في علامة تبويب أخرى. توقف عن العرض لتسليم المنصة.",
"created": "تم إنشاء الغرفة",
"invited": "الأعضاء المدعوون",
"roomTab": "مقابلة"
},
"nav": {
"collab": "الاجتماعات"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "السماح بمشاركة الجلسة",
"description": "يسمح هذا الخيار بمشاركة جلسات الطرفية المباشرة، وجلسات RDP، وVNC، وTelnet على مستوى الجهاز. ويتجاوز هذا الخيار جميع إعدادات المشاركة الخاصة بكل مضيف عند تعطيله."
}
},
"host": {
"allowSessionSharing": {
"label": "السماح بمشاركة الجلسة",
"description": "يمكن مشاركة الجلسات المباشرة على هذا المضيف عبر رابط أو مع مستخدمين آخرين"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Линкът за споделяне е създаден",
"linkCopied": "Връзката е копирана в буферната памет",
"copyLink": "Копиране на връзката",
"shareCreated": "Сесията е споделена. Тя ще се покаже в раздела им „Връзки“.",
"shareFailed": "Създаването на споделено съдържание не бе успешно",
"userLacksHostAccess": "Тозият потребител все още няма достъп до този хост. Първо споделете хоста с него или използвайте линк.",
"activeShares": "Активни акции",
"noActiveShares": "Няма активни споделяния за тази сесия",
"revoke": "Отмяна",
"revokeConfirmTitle": "Да се отмени ли това споделяне?",
"revokeConfirmDescription": "Всеки, който използва това споделяне, ще загуби достъп незабавно.",
"revoked": "Акцията е отменена",
"revokeFailed": "Неуспешно отменяне на споделянето",
"joinCount": "{{count}} присъединяване",
"joinCount_other": "{{count}} се присъединява",
"expiresAt": "Изтича {{date}}",
"linkShareBadge": "Връзка",
"userShareBadge": "Потребител: {{username}}",
"loadSharesFailed": "Зареждането на активните споделяния не бе успешно",
"customHoursPlaceholder": "Часове до изтичане на срока на достъп"
},
"collab": {
"createRoom": "Нова стая",
"roomName": "Име на стаята",
"persistentRoom": "Постоянна стая",
"persistentRoomHint": "Постоянните стаи остават в списъка след края на срещата и могат да бъдат използвани повторно.",
"noRooms": "Все още няма стаи. Създайте една, за да започнете среща.",
"openRoom": "Отворено",
"endRoom": "Край на срещата",
"leaveRoom": "Оставете",
"deleteMember": "Премахване",
"removeMember": "Премахване на {{name}}",
"removeMemberTitle": "Да се премахне ли този член?",
"removeMemberDescription": "{{name}} ще загуби достъп до тази стая незабавно. Всеки активен SSH зрител ще бъде прекъснат.",
"invite": "Покани",
"inviteTitle": "Поканете членове",
"members": "Членове",
"membersPanel": "Членове на стаята и заявки за контрол",
"membersWithCount": "Членове ({{count}})",
"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": "Този раздел за среща е с изтекъл срок. Отворете отново стаята от панела „Срещи“.",
"requestControl": "Заявка за контрол",
"cancelControlRequest": "Отмяна на заявката",
"controlQueue": "Заявки за контрол",
"noControlRequests": "Няма чакащи заявки.",
"grantControlTo": "Дайте контрол на {{name}}",
"dismissControlRequest": "Отхвърляне на заявката за контрол на {{name}}",
"releaseControl": "Контрол на освобождаването",
"grantControl": "Дайте контрол",
"revokeControl": "Върнете си контрола",
"controlRequestedBy": "{{name}} поиска контрол",
"grant": "Грант",
"controlBadge": "В контрол",
"youArePresenting": "Презентирате в друг раздел. Спрете презентацията, за да предадете сцената.",
"created": "Стаята е създадена",
"invited": "Поканени членове",
"roomTab": "Среща"
},
"nav": {
"collab": "Срещи"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Разрешаване на споделяне на сесия",
"description": "Разрешава споделянето на сесии на живо на терминал, RDP, VNC и Telnet в целия екземпляр. Заменя всяко превключвател за споделяне на хост, когато е деактивиран."
}
},
"host": {
"allowSessionSharing": {
"label": "Разрешаване на споделяне на сесия",
"description": "Позволете споделянето на сесиите на живо на този хост чрез линк или с други потребители"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "শেয়ার লিঙ্ক তৈরি করা হয়েছে",
"linkCopied": "লিঙ্ক ক্লিপবোর্ডে কপি করা হয়েছে",
"copyLink": "লিঙ্ক কপি করুন",
"shareCreated": "সেশনটি শেয়ার করা হয়েছে। এটি তাদের কানেকশনস ট্যাবে দেখা যাবে।",
"shareFailed": "শেয়ার তৈরি করতে ব্যর্থ হয়েছে",
"userLacksHostAccess": "ওই ব্যবহারকারীর এখনও এই হোস্টে অ্যাক্সেস নেই। প্রথমে তাদের সাথে হোস্টটি শেয়ার করুন, অথবা এর পরিবর্তে একটি লিঙ্ক ব্যবহার করুন।",
"activeShares": "সক্রিয় শেয়ার",
"noActiveShares": "এই সেশনের জন্য কোনো সক্রিয় শেয়ার নেই",
"revoke": "বাতিল করুন",
"revokeConfirmTitle": "এই শেয়ারটি প্রত্যাহার করবেন?",
"revokeConfirmDescription": "যে কেউ এই শেয়ারটি ব্যবহার করলে, তিনি অবিলম্বে অ্যাক্সেস হারাবেন।",
"revoked": "শেয়ার প্রত্যাহার করা হয়েছে",
"revokeFailed": "শেয়ার প্রত্যাহার করতে ব্যর্থ হয়েছে",
"joinCount": "{{count}} যোগদান করুন",
"joinCount_other": "{{count}} যোগদান করে",
"expiresAt": "মেয়াদ শেষ হবে {{date}}",
"linkShareBadge": "লিঙ্ক",
"userShareBadge": "ব্যবহারকারী: {{username}}",
"loadSharesFailed": "সক্রিয় শেয়ারগুলি লোড করতে ব্যর্থ হয়েছে",
"customHoursPlaceholder": "অ্যাক্সেসের মেয়াদ শেষ হতে বাকি ঘন্টা"
},
"collab": {
"createRoom": "নতুন ঘর",
"roomName": "রুমের নাম",
"persistentRoom": "স্থায়ী কক্ষ",
"persistentRoomHint": "মিটিং শেষ হওয়ার পরেও স্থায়ী রুমগুলো তালিকাভুক্ত থাকে এবং পুনরায় ব্যবহার করা যায়।",
"noRooms": "এখনো কোনো রুম নেই। মিটিং শুরু করতে একটি রুম তৈরি করুন।",
"openRoom": "খোলা",
"endRoom": "সভা শেষ করুন",
"leaveRoom": "ছেড়ে দিন",
"deleteMember": "অপসারণ করুন",
"removeMember": "{{name}} মুছে ফেলুন",
"removeMemberTitle": "এই সদস্যকে অপসারণ করবেন?",
"removeMemberDescription": "{{name}} অবিলম্বে এই রুমে প্রবেশাধিকার হারাবে। যেকোনো সক্রিয় SSH ভিউয়ার সংযোগ বিচ্ছিন্ন হয়ে যাবে।",
"invite": "আমন্ত্রণ",
"inviteTitle": "সদস্যদের আমন্ত্রণ জানান",
"members": "সদস্যরা",
"membersPanel": "রুমের সদস্য এবং নিয়ন্ত্রণ অনুরোধ",
"membersWithCount": "সদস্যগণ ({{count}})",
"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": "এই মিটিং ট্যাবটির মেয়াদ শেষ হয়ে গেছে। মিটিং প্যানেল থেকে রুমটি পুনরায় খুলুন।",
"requestControl": "অনুরোধ নিয়ন্ত্রণ",
"cancelControlRequest": "অনুরোধ বাতিল করুন",
"controlQueue": "নিয়ন্ত্রণ অনুরোধ",
"noControlRequests": "কোনো অপেক্ষমান অনুরোধ নেই।",
"grantControlTo": "{{name}}-কে নিয়ন্ত্রণ দিন",
"dismissControlRequest": "{{name}}এর নিয়ন্ত্রণ অনুরোধ খারিজ করুন",
"releaseControl": "রিলিজ নিয়ন্ত্রণ",
"grantControl": "নিয়ন্ত্রণ দিন",
"revokeControl": "নিয়ন্ত্রণ ফিরিয়ে নিন",
"controlRequestedBy": "{{name}} নিয়ন্ত্রণের জন্য অনুরোধ করেছে",
"grant": "অনুদান",
"controlBadge": "নিয়ন্ত্রণে",
"youArePresenting": "আপনি অন্য একটি ট্যাবে প্রেজেন্টেশন দিচ্ছেন। মঞ্চ হস্তান্তর করার জন্য প্রেজেন্টেশন বন্ধ করুন।",
"created": "রুম তৈরি করা হয়েছে",
"invited": "সদস্যদের আমন্ত্রণ জানানো হয়েছে",
"roomTab": "সভা"
},
"nav": {
"collab": "সভা"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "সেশন শেয়ারিং অনুমোদন করুন",
"description": "লাইভ টার্মিনাল, RDP, VNC, এবং টেলনেট সেশনগুলোকে ইনস্ট্যান্স-ব্যাপী শেয়ার করার অনুমতি দিন। এটি নিষ্ক্রিয় করা হলে, প্রতি-হোস্ট শেয়ারিং টগলকে ওভাররাইড করে।"
}
},
"host": {
"allowSessionSharing": {
"label": "সেশন শেয়ারিং অনুমোদন করুন",
"description": "এই হোস্টের লাইভ সেশনগুলো লিঙ্কের মাধ্যমে বা অন্যান্য ব্যবহারকারীদের সাথে শেয়ার করা যাবে।"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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",
"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}})",
"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.",
"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ó"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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í",
"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}})",
"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.",
"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í"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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",
"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}})",
"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.",
"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"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Aktie",
"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": "Nur lesbar",
"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",
"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",
"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": "Entferne {{name}}",
"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}})",
"online": "online",
"offline": "offline",
"you": "(Du)",
"hostBadge": "Gastgeber",
"presenterBadge": "Präsentation",
"present": "Gegenwärtig",
"presentTitle": "Betretet die Bühne",
"presentHost": "Gastgeber",
"searchHosts": "Suchmaschinen",
"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ü.",
"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"
},
"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."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Ο σύνδεσμος κοινής χρήσης δημιουργήθηκε",
"linkCopied": "Ο σύνδεσμος αντιγράφηκε στο πρόχειρο",
"copyLink": "Αντιγραφή συνδέσμου",
"shareCreated": "Η συνεδρία κοινοποιήθηκε. Θα εμφανιστεί στην καρτέλα \"Συνδέσεις\".",
"shareFailed": "Η δημιουργία κοινής χρήσης απέτυχε",
"userLacksHostAccess": "Αυτός ο χρήστης δεν έχει ακόμη πρόσβαση σε αυτόν τον κεντρικό υπολογιστή. Κοινοποιήστε πρώτα τον κεντρικό υπολογιστή μαζί του ή χρησιμοποιήστε έναν σύνδεσμο.",
"activeShares": "Ενεργές κοινοποιήσεις",
"noActiveShares": "Δεν υπάρχουν ενεργές κοινοποιήσεις για αυτήν την περίοδο σύνδεσης",
"revoke": "Ανακαλώ",
"revokeConfirmTitle": "Ανάκληση αυτής της κοινοποίησης;",
"revokeConfirmDescription": "Όποιος χρησιμοποιεί αυτήν την κοινοποίηση θα χάσει αμέσως την πρόσβαση.",
"revoked": "Η κοινοποίηση ανακλήθηκε",
"revokeFailed": "Η ανάκληση της κοινής χρήσης απέτυχε",
"joinCount": "{{count}} συμμετοχή",
"joinCount_other": "{{count}} ενώσεις",
"expiresAt": "Λήγει {{date}}",
"linkShareBadge": "Σύνδεσμος",
"userShareBadge": "Χρήστης: {{username}}",
"loadSharesFailed": "Αποτυχία φόρτωσης ενεργών κοινόχρηστων στοιχείων",
"customHoursPlaceholder": "Ώρες μέχρι τη λήξη της πρόσβασης"
},
"collab": {
"createRoom": "Νέο δωμάτιο",
"roomName": "Όνομα δωματίου",
"persistentRoom": "Μόνιμο δωμάτιο",
"persistentRoomHint": "Οι μόνιμες αίθουσες παραμένουν καταχωρημένες μετά το τέλος της σύσκεψης και μπορούν να επαναχρησιμοποιηθούν.",
"noRooms": "Δεν υπάρχουν ακόμη αίθουσες. Δημιουργήστε μία για να ξεκινήσετε μια σύσκεψη.",
"openRoom": "Ανοιχτό",
"endRoom": "Λήξη συνάντησης",
"leaveRoom": "Αδεια",
"deleteMember": "Αφαιρώ",
"removeMember": "Αφαίρεση {{name}}",
"removeMemberTitle": "Κατάργηση αυτού του μέλους;",
"removeMemberDescription": "Ο χρήστης {{name}} θα χάσει αμέσως την πρόσβαση σε αυτό το δωμάτιο. Οποιοσδήποτε ενεργός χρήστης προβολής SSH θα αποσυνδεθεί.",
"invite": "Καλώ",
"inviteTitle": "Πρόσκληση μελών",
"members": "Μέλη",
"membersPanel": "Μέλη δωματίου και αιτήματα ελέγχου",
"membersWithCount": "Μέλη ({{count}})",
"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": "Αυτή η καρτέλα σύσκεψης έληξε. Ανοίξτε ξανά την αίθουσα από τον πίνακα \"Συνέδρια\".",
"requestControl": "Αίτημα ελέγχου",
"cancelControlRequest": "Αίτημα ακύρωσης",
"controlQueue": "Αιτήματα ελέγχου",
"noControlRequests": "Δεν υπάρχουν εκκρεμή αιτήματα.",
"grantControlTo": "Δώστε τον έλεγχο σε {{name}}",
"dismissControlRequest": "Απόρριψη αιτήματος ελέγχου του {{name}}",
"releaseControl": "Έλεγχος απελευθέρωσης",
"grantControl": "Δώστε τον έλεγχο",
"revokeControl": "Πάρε πίσω τον έλεγχο",
"controlRequestedBy": "{{name}} ζήτησε έλεγχο",
"grant": "Χορήγηση",
"controlBadge": "Υπό έλεγχο",
"youArePresenting": "Κάνετε παρουσίαση σε άλλη καρτέλα. Διακόψτε την παρουσίαση για να παραδώσετε τη σκηνή.",
"created": "Δημιουργήθηκε δωμάτιο",
"invited": "Μέλη που έχουν προσκληθεί",
"roomTab": "Συνάντηση"
},
"nav": {
"collab": "Συναντήσεις"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Επιτρέψτε την κοινή χρήση περιόδου σύνδεσης",
"description": "Επιτρέπεται η κοινή χρήση των ζωντανών συνεδριών τερματικού, RDP, VNC και Telnet σε ολόκληρη την παρουσία. Παρακάμπτει κάθε εναλλαγή κοινής χρήσης ανά κεντρικό υπολογιστή όταν είναι απενεργοποιημένη."
}
},
"host": {
"allowSessionSharing": {
"label": "Επιτρέψτε την κοινή χρήση περιόδου σύνδεσης",
"description": "Να επιτρέπεται η κοινοποίηση ζωντανών συνεδριών σε αυτόν τον πάροχο μέσω συνδέσμου ή με άλλους χρήστες"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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.",
"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": "Abierto",
"endRoom": "Fin de la reunión",
"leaveRoom": "Dejar",
"deleteMember": "Eliminar",
"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}})",
"online": "en línea",
"offline": "desconectado",
"you": "(tú)",
"hostBadge": "Anfitrión",
"presenterBadge": "Presentación",
"present": "Presente",
"presentTitle": "Sube al escenario",
"presentHost": "Anfitrión",
"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.",
"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"
},
"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."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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",
"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}})",
"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.",
"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"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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 d'autorisation",
"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éé",
"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 {{date}}",
"linkShareBadge": "Lien",
"userShareBadge": "Utilisateur : {{username}}",
"loadSharesFailed": "Échec du chargement des actions actives",
"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}})",
"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.",
"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"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "קישור השיתוף נוצר",
"linkCopied": "קישור שהועתק ללוח",
"copyLink": "העתקת קישור",
"shareCreated": "הסשן שותף. זה יופיע בלשונית החיבורים שלהם.",
"shareFailed": "יצירת השיתוף נכשלה",
"userLacksHostAccess": "למשתמש הזה עדיין אין גישה למארח הזה. שתף את המארח איתו תחילה, או השתמש בקישור במקום זאת.",
"activeShares": "מניות פעילות",
"noActiveShares": "אין שיתופים פעילים עבור סשן זה",
"revoke": "לְבַטֵל",
"revokeConfirmTitle": "לבטל את השיתוף הזה?",
"revokeConfirmDescription": "כל מי שישתמש בשיתוף הזה יאבד גישה באופן מיידי.",
"revoked": "השיתוף בוטל",
"revokeFailed": "ביטול השיתוף נכשל",
"joinCount": "{{count}} join",
"joinCount_other": "{{count}} מצטרף",
"expiresAt": "פג תוקף {{date}}",
"linkShareBadge": "לְקַשֵׁר",
"userShareBadge": "משתמש: {{username}}",
"loadSharesFailed": "נכשלה טעינת השיתופים הפעילים",
"customHoursPlaceholder": "שעות עד לפקיעת הגישה"
},
"collab": {
"createRoom": "חדר חדש",
"roomName": "שם החדר",
"persistentRoom": "חדר קבוע",
"persistentRoomHint": "חדרים קבועים נשארים רשומים לאחר סיום הפגישה וניתן לעשות בהם שימוש חוזר.",
"noRooms": "אין עדיין חדרים. צור אחד כדי להתחיל פגישה.",
"openRoom": "לִפְתוֹחַ",
"endRoom": "סיום פגישה",
"leaveRoom": "לַעֲזוֹב",
"deleteMember": "לְהַסִיר",
"removeMember": "הסר {{name}}",
"removeMemberTitle": "להסיר את החבר הזה?",
"removeMemberDescription": "{{name}} יאבד גישה לחדר הזה באופן מיידי. כל צופה פעיל ב-SSH ינותק.",
"invite": "לְהַזמִין",
"inviteTitle": "הזמנת חברים",
"members": "חברים",
"membersPanel": "חברי חדר ובקשות בקרה",
"membersWithCount": "חברים ({{count}})",
"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": "פג תוקף כרטיסיית הפגישה הזו. פתח מחדש את החדר מחלונית הפגישות.",
"requestControl": "בקרת בקשות",
"cancelControlRequest": "ביטול בקשה",
"controlQueue": "בקשות בקרה",
"noControlRequests": "אין בקשות ממתינות.",
"grantControlTo": "תן שליטה ל- {{name}}",
"dismissControlRequest": "סגור את בקשת הבקרה של {{name}}",
"releaseControl": "בקרת שחרור",
"grantControl": "תן שליטה",
"revokeControl": "קח בחזרה את השליטה",
"controlRequestedBy": "{{name}} ביקש שליטה",
"grant": "מַעֲנָק",
"controlBadge": "בשליטה",
"youArePresenting": "אתה מציג בכרטיסייה אחרת. הפסק את ההצגה כדי להעביר את הבמה.",
"created": "החדר נוצר",
"invited": "חברים שהוזמנו",
"roomTab": "פְּגִישָׁה"
},
"nav": {
"collab": "פגישות"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "אפשר שיתוף סשנים",
"description": "מאפשר שיתוף של הפעלות מסוף חי, RDP, VNC ו-Telnet בכל המופעים. מבטל כל אפשרות שיתוף לפי מארח כאשר היא מושבתת."
}
},
"host": {
"allowSessionSharing": {
"label": "אפשר שיתוף סשנים",
"description": "אפשר שיתוף של מפגשים חיים במארח זה באמצעות קישור או עם משתמשים אחרים"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "शेयर लिंक बनाया गया",
"linkCopied": "लिंक क्लिपबोर्ड पर कॉपी हो गया है",
"copyLink": "लिंक की प्रतिलिपि करें",
"shareCreated": "सेशन साझा कर दिया गया है। यह उनके कनेक्शन टैब में दिखाई देगा।",
"shareFailed": "शेयर बनाने में विफल",
"userLacksHostAccess": "उस उपयोगकर्ता के पास अभी इस होस्ट तक पहुंच नहीं है। कृपया पहले उनके साथ होस्ट साझा करें, या इसके बजाय एक लिंक का उपयोग करें।",
"activeShares": "सक्रिय शेयर",
"noActiveShares": "इस सत्र के लिए कोई सक्रिय शेयर नहीं हैं",
"revoke": "रद्द करना",
"revokeConfirmTitle": "क्या इस शेयर को रद्द कर दें?",
"revokeConfirmDescription": "इस शेयर का उपयोग करने वाले किसी भी व्यक्ति की पहुंच तुरंत समाप्त हो जाएगी।",
"revoked": "शेयर रद्द कर दिया गया",
"revokeFailed": "शेयर रद्द करने में विफल",
"joinCount": "{{count}} जुड़ें",
"joinCount_other": "{{count}} जुड़ता है",
"expiresAt": "समाप्ति तिथि {{date}}",
"linkShareBadge": "जोड़ना",
"userShareBadge": "उपयोगकर्ता: {{username}}",
"loadSharesFailed": "सक्रिय शेयर लोड करने में विफल",
"customHoursPlaceholder": "पहुँच समाप्त होने तक के घंटे"
},
"collab": {
"createRoom": "नया कमरा",
"roomName": "कमरे का नाम",
"persistentRoom": "लगातार कमरा",
"persistentRoomHint": "स्थायी कमरे मीटिंग समाप्त होने के बाद भी सूची में बने रहते हैं और उनका पुनः उपयोग किया जा सकता है।",
"noRooms": "अभी कोई कमरा उपलब्ध नहीं है। मीटिंग शुरू करने के लिए एक कमरा बनाएं।",
"openRoom": "खुला",
"endRoom": "बैठक समाप्त",
"leaveRoom": "छुट्टी",
"deleteMember": "निकालना",
"removeMember": "{{name}} हटाएँ",
"removeMemberTitle": "क्या इस सदस्य को हटा दें?",
"removeMemberDescription": "{{name}} को इस कमरे तक पहुँच तुरंत खोनी पड़ेगी। कोई भी सक्रिय SSH व्यूअर डिस्कनेक्ट हो जाएगा।",
"invite": "आमंत्रित करना",
"inviteTitle": "सदस्यों को आमंत्रित करो",
"members": "सदस्यों",
"membersPanel": "कमरे के सदस्य और नियंत्रण अनुरोध",
"membersWithCount": "सदस्य ({{count}})",
"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": "वर्तमान लिंक तुरंत काम करना बंद कर देगा। कनेक्टेड एसएसएच गेस्ट डिस्कनेक्ट हो जाएंगे।",
"disableLinkConfirmTitle": "अतिथि लिंक को निष्क्रिय करें?",
"disableLinkDescription": "अनाम एसएसएच व्यूअर्स तुरंत डिस्कनेक्ट हो जाएंगे और वर्तमान लिंक काम करना बंद कर देगा।",
"linkCopied": "लिंक कॉपी हो गया",
"roles": "भूमिकाएँ",
"users": "उपयोगकर्ताओं",
"searchUsers": "उपयोगकर्ताओं को खोजें",
"endConfirmTitle": "क्या इस बैठक को यहीं समाप्त करें?",
"endDescription": "{{name}} सभी के लिए समाप्त हो जाएगा और इसे दोबारा नहीं खोला जा सकता है।",
"endPersistentDescription": "{{name}} में चल रही प्रस्तुति बंद हो जाएगी। कमरा पुनः उपयोग के लिए उपलब्ध रहेगा।",
"deleteRoom": "कमरा हटाएं",
"deleteConfirmTitle": "क्या इस कमरे को हटा दें?",
"deleteDescription": "{{name}} को सभी के लिए स्थायी रूप से हटा दिया जाएगा और इसे पूर्ववत नहीं किया जा सकता है।",
"invitedTo": "आपको \"{{name}} \" में आमंत्रित किया गया था",
"guest": {
"title": "बैठक",
"waiting": "प्रस्तुतकर्ता का इंतजार है...",
"linkInvalid": "यह अतिथि लिंक अमान्य है या मीटिंग समाप्त हो चुकी है"
},
"reopenFromPanel": "इस मीटिंग टैब की समय सीमा समाप्त हो गई है। कृपया मीटिंग पैनल से कमरे को पुनः खोलें।",
"requestControl": "अनुरोध नियंत्रण",
"cancelControlRequest": "अनुरोध को रद्द करें",
"controlQueue": "नियंत्रण अनुरोध",
"noControlRequests": "कोई लंबित अनुरोध नहीं।",
"grantControlTo": "{{name}} को नियंत्रण दें",
"dismissControlRequest": "{{name}}के नियंत्रण अनुरोध को खारिज करें",
"releaseControl": "नियंत्रण छोड़ें",
"grantControl": "नियंत्रण सौंप दें",
"revokeControl": "नियंत्रण वापस अपने हाथ में लें।",
"controlRequestedBy": "{{name}} ने नियंत्रण मांगा",
"grant": "अनुदान",
"controlBadge": "नियंत्रण में",
"youArePresenting": "आप किसी दूसरे टैब में प्रेजेंटेशन दे रहे हैं। कृपया प्रेजेंटेशन देना बंद करें और स्टेज किसी और को सौंप दें।",
"created": "कमरा बनाया गया",
"invited": "सदस्यों को आमंत्रित किया गया",
"roomTab": "बैठक"
},
"nav": {
"collab": "बैठक"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "सेशन शेयरिंग की अनुमति दें",
"description": "लाइव टर्मिनल, RDP, VNC और Telnet सेशन को पूरे इंस्टेंस में साझा करने की अनुमति दें। अक्षम होने पर यह प्रति-होस्ट साझाकरण टॉगल को ओवरराइड कर देता है।"
}
},
"host": {
"allowSessionSharing": {
"label": "सेशन शेयरिंग की अनुमति दें",
"description": "इस होस्ट पर होने वाले लाइव सेशन को लिंक के माध्यम से या अन्य उपयोगकर्ताओं के साथ साझा किया जा सकता है।"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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",
"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}})",
"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.",
"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ó"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Menghubungkan ke sesi bersama...",
"linkInvalid": "Tautan berbagi ini tidak valid, telah kedaluwarsa, atau telah dicabut.",
"rateLimited": "Terlalu banyak percobaan, silakan coba lagi sebentar lagi.",
"sessionEnded": "Sesi ini telah berakhir.",
"readOnlyBadge": "Hanya tampilan",
"ownerLabel": "Pemilik host"
},
"modalTitle": "Sesi berbagi",
"shareButton": "Membagikan",
"notReadyToShare": "Sesi belum siap untuk dibagikan.",
"modeTab": {
"link": "Link",
"user": "Pengguna"
},
"linkModeDescription": "Siapa pun yang memiliki tautan ini dapat bergabung, tidak perlu membuat akun.",
"userModeDescription": "Bagikan dengan pengguna tertentu yang sudah memiliki akses ke host ini. Jika mereka belum memiliki akses, bagikan host tersebut kepada mereka terlebih dahulu atau gunakan tautan. Setelah dibagikan, sesi akan muncul di tab Koneksi mereka.",
"permissionLevel": {
"label": "Tingkat izin",
"readOnly": "Hanya baca",
"readOnlyDescription": "Bisa menonton sesi secara langsung tetapi tidak bisa mengetik atau berinteraksi.",
"readWrite": "Baca-tulis",
"readWriteDescription": "Dapat mengetik dan berinteraksi dengan sesi seperti halnya pemilik."
},
"expiryLabel": "Kedaluwarsa tautan",
"createLinkButton": "Buat tautan",
"createShareButton": "Bagikan dengan pengguna",
"searchUsersPlaceholder": "Cari pengguna...",
"noUsersFound": "Tidak ada pengguna yang ditemukan",
"linkCreated": "Tautan berbagi telah dibuat",
"linkCopied": "Tautan disalin ke papan klip",
"copyLink": "Salin tautan",
"shareCreated": "Sesi dibagikan. Sesi tersebut akan muncul di tab Koneksi mereka.",
"shareFailed": "Gagal membuat saham",
"userLacksHostAccess": "Pengguna tersebut belum memiliki akses ke host ini. Bagikan host tersebut kepada mereka terlebih dahulu, atau gunakan tautan sebagai gantinya.",
"activeShares": "Saham aktif",
"noActiveShares": "Tidak ada saham aktif untuk sesi ini.",
"revoke": "Menarik kembali",
"revokeConfirmTitle": "Batalkan pembagian ini?",
"revokeConfirmDescription": "Siapa pun yang menggunakan share ini akan langsung kehilangan akses.",
"revoked": "Saham dicabut",
"revokeFailed": "Gagal mencabut saham",
"joinCount": "{{count}} bergabung",
"joinCount_other": "{{count}} bergabung",
"expiresAt": "Berakhir {{date}}",
"linkShareBadge": "Link",
"userShareBadge": "Pengguna: {{username}}",
"loadSharesFailed": "Gagal memuat saham aktif.",
"customHoursPlaceholder": "Waktu hingga akses berakhir"
},
"collab": {
"createRoom": "Kamar baru",
"roomName": "Nama ruangan",
"persistentRoom": "Ruang permanen",
"persistentRoomHint": "Ruangan yang bersifat permanen akan tetap terdaftar setelah rapat berakhir dan dapat digunakan kembali.",
"noRooms": "Belum ada ruang rapat. Buat ruang rapat untuk memulai pertemuan.",
"openRoom": "Membuka",
"endRoom": "Mengakhiri pertemuan",
"leaveRoom": "Meninggalkan",
"deleteMember": "Menghapus",
"removeMember": "Hapus {{name}}",
"removeMemberTitle": "Hapus anggota ini?",
"removeMemberDescription": "{{name}} akan kehilangan akses ke ruangan ini segera. Setiap penonton SSH yang aktif akan terputus.",
"invite": "Mengundang",
"inviteTitle": "Undang anggota",
"members": "Anggota",
"membersPanel": "Anggota ruangan dan permintaan kontrol",
"membersWithCount": "Anggota ({{count}})",
"online": "on line",
"offline": "offline",
"you": "(Anda)",
"hostBadge": "Tuan rumah",
"presenterBadge": "Mempersembahkan",
"present": "Hadiah",
"presentTitle": "Naik ke panggung",
"presentHost": "Tuan rumah",
"searchHosts": "Cari host",
"noHostsFound": "Tidak ditemukan host yang tersedia.",
"presentProtocol": "Protokol",
"stopPresenting": "Berhenti melakukan presentasi",
"takeOver": "Mengambil alih",
"takeOverConfirmTitle": "Mengganti presentasi yang ada?",
"takeOverDescription": "{{name}} sedang melakukan presentasi sekarang. Mengambil alih akan memutuskan koneksi semua orang dari panggung tersebut.",
"emptyStage": "Tidak ada yang sedang presentasi. Silakan naik ke panggung untuk berbagi sesi.",
"stageLoading": "Terhubung ke panggung...",
"stageEnded": "Presentasi berakhir",
"roomEnded": "Pertemuan ini telah berakhir.",
"presenterLabel": "{{name}} sedang menampilkan",
"guestLink": "Tautan tamu",
"guestLinkOn": "Tautan tamu sudah aktif. Siapa pun yang memiliki tautan tersebut dapat menonton pertunjukan di panggung.",
"guestLinkOff": "Tautan tamu dinonaktifkan.",
"copyLink": "Salin tautan",
"rotateLink": "Putar tautan",
"disableLink": "Nonaktifkan tautan",
"rotateLinkConfirmTitle": "Putar tautan tamu?",
"rotateLinkDescription": "Koneksi saat ini akan segera berhenti berfungsi. Tamu SSH yang terhubung akan terputus.",
"disableLinkConfirmTitle": "Nonaktifkan tautan tamu?",
"disableLinkDescription": "Pengguna anonim yang mengakses melalui SSH akan langsung terputus dan tautan saat ini akan berhenti berfungsi.",
"linkCopied": "Tautan disalin",
"roles": "Peran",
"users": "Pengguna",
"searchUsers": "Cari pengguna",
"endConfirmTitle": "Apakah pertemuan ini bisa diakhiri?",
"endDescription": "{{name}} akan berakhir untuk semua orang dan tidak dapat dibuka kembali.",
"endPersistentDescription": "Presentasi aktif di {{name}} akan berhenti. Ruangan tetap tersedia untuk digunakan kembali.",
"deleteRoom": "Hapus ruangan",
"deleteConfirmTitle": "Hapus ruangan ini?",
"deleteDescription": "{{name}} akan dihapus secara permanen untuk semua orang dan tidak dapat dibatalkan.",
"invitedTo": "Anda diundang ke \"{{name}}\"",
"guest": {
"title": "Pertemuan",
"waiting": "Menunggu pembawa acara...",
"linkInvalid": "Tautan tamu ini tidak valid atau rapat telah berakhir."
},
"reopenFromPanel": "Tab rapat ini telah kedaluwarsa. Buka kembali ruangan dari panel Rapat.",
"requestControl": "Kontrol permintaan",
"cancelControlRequest": "Batalkan permintaan",
"controlQueue": "Permintaan kontrol",
"noControlRequests": "Tidak ada permintaan yang tertunda.",
"grantControlTo": "Berikan kendali ke {{name}}",
"dismissControlRequest": "Abaikan permintaan kontrol {{name}}",
"releaseControl": "Lepaskan kontrol",
"grantControl": "Berikan kendali",
"revokeControl": "Ambil kembali kendali",
"controlRequestedBy": "{{name}} meminta kendali",
"grant": "Menganugerahkan",
"controlBadge": "Dalam kendali",
"youArePresenting": "Anda sedang melakukan presentasi di tab lain. Hentikan presentasi untuk memberikan kesempatan kepada saya.",
"created": "Ruangan dibuat",
"invited": "Anggota diundang",
"roomTab": "Pertemuan"
},
"nav": {
"collab": "Pertemuan"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Izinkan Berbagi Sesi",
"description": "Izinkan sesi terminal langsung, RDP, VNC, dan Telnet untuk dibagikan di seluruh instance. Menggantikan setiap pengaturan berbagi per host saat dinonaktifkan."
}
},
"host": {
"allowSessionSharing": {
"label": "Izinkan Berbagi Sesi",
"description": "Izinkan sesi langsung di host ini dibagikan melalui tautan atau dengan pengguna lain."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Condividere",
"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",
"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": "Revocare",
"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",
"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": "Aprire",
"endRoom": "Fine della riunione",
"leaveRoom": "Partire",
"deleteMember": "Rimuovere",
"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}})",
"online": "online",
"offline": "offline",
"you": "(Voi)",
"hostBadge": "Ospite",
"presenterBadge": "Presentazione",
"present": "Presente",
"presentTitle": "Sali sul palco",
"presentHost": "Ospite",
"searchHosts": "Host di ricerca",
"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": "Link 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.",
"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"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "共有リンクが作成されました",
"linkCopied": "リンクがクリップボードにコピーされました",
"copyLink": "リンクをコピー",
"shareCreated": "セッションが共有されました。相手の「接続」タブに表示されます。",
"shareFailed": "共有の作成に失敗しました",
"userLacksHostAccess": "そのユーザーはまだこのホストへのアクセス権を持っていません。まずホストを共有するか、代わりにリンクを使用してください。",
"activeShares": "アクティブなシェア",
"noActiveShares": "今セッションで有効な株式はありません",
"revoke": "取り消す",
"revokeConfirmTitle": "この共有を取り消しますか?",
"revokeConfirmDescription": "この共有ファイルを使用している人は、即座にアクセス権を失います。",
"revoked": "シェアが取り消されました",
"revokeFailed": "シェアの取り消しに失敗しました",
"joinCount": "{{count}} 参加する",
"joinCount_other": "{{count}} が参加します",
"expiresAt": "有効期限 {{date}}",
"linkShareBadge": "リンク",
"userShareBadge": "ユーザー: {{username}}",
"loadSharesFailed": "アクティブな共有の読み込みに失敗しました",
"customHoursPlaceholder": "アクセス期限までの時間数"
},
"collab": {
"createRoom": "新しい部屋",
"roomName": "部屋名",
"persistentRoom": "永続的な部屋",
"persistentRoomHint": "永続的な会議室は、会議終了後もリストに表示され、再利用できます。",
"noRooms": "まだ会議室がありません。会議室を作成して会議を開始してください。",
"openRoom": "開ける",
"endRoom": "会議終了",
"leaveRoom": "離れる",
"deleteMember": "取り除く",
"removeMember": "{{name}} を削除",
"removeMemberTitle": "このメンバーを削除しますか?",
"removeMemberDescription": "{{name}} は直ちにこのルームへのアクセス権を失います。アクティブな SSH ビューアはすべて切断されます。",
"invite": "招待する",
"inviteTitle": "メンバーを招待する",
"members": "メンバー",
"membersPanel": "ルームメンバーと制御リクエスト",
"membersWithCount": "メンバー({{count}})",
"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": "この会議タブの有効期限が切れました。会議パネルから会議室を再度開いてください。",
"requestControl": "リクエスト制御",
"cancelControlRequest": "キャンセルリクエスト",
"controlQueue": "制御要求",
"noControlRequests": "保留中のリクエストはありません。",
"grantControlTo": "{{name}}に制御権を与える",
"dismissControlRequest": "{{name}}の制御要求を却下します",
"releaseControl": "リリース制御",
"grantControl": "コントロールを与える",
"revokeControl": "主導権を取り戻す",
"controlRequestedBy": "{{name}} が制御を要求しました",
"grant": "付与",
"controlBadge": "制御する",
"youArePresenting": "あなたは別のタブでプレゼンテーションを行っています。プレゼンテーションを中断して、ステージを譲ってください。",
"created": "ルームが作成されました",
"invited": "会員の皆様を招待いたします",
"roomTab": "ミーティング"
},
"nav": {
"collab": "会議"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "セッション共有を許可する",
"description": "ライブターミナル、RDP、VNC、およびTelnetセッションをインスタンス全体で共有できるようにします。無効にすると、ホストごとの共有設定がすべて上書きされます。"
}
},
"host": {
"allowSessionSharing": {
"label": "セッション共有を許可する",
"description": "このホスト上のライブセッションをリンクまたは他のユーザーと共有できるようにします。"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "공유 링크가 생성되었습니다",
"linkCopied": "링크가 클립보드에 복사되었습니다",
"copyLink": "링크 복사",
"shareCreated": "세션이 공유되었습니다. 상대방의 연결 탭에 표시됩니다.",
"shareFailed": "공유 폴더 생성에 실패했습니다.",
"userLacksHostAccess": "해당 사용자는 아직 이 호스트에 대한 접근 권한이 없습니다. 먼저 호스트를 공유하거나 링크를 사용하세요.",
"activeShares": "활성 주식",
"noActiveShares": "이번 세션에는 활성 주식이 없습니다.",
"revoke": "취소",
"revokeConfirmTitle": "이 지분을 취소하시겠습니까?",
"revokeConfirmDescription": "이 공유 폴더를 사용하는 사람은 누구든 즉시 접근 권한을 잃게 됩니다.",
"revoked": "공유 취소됨",
"revokeFailed": "공유 취소에 실패했습니다",
"joinCount": "{{count}} 참여",
"joinCount_other": "{{count}} 참여",
"expiresAt": "만료일 {{date}}",
"linkShareBadge": "링크",
"userShareBadge": "사용자: {{username}}",
"loadSharesFailed": "활성 공유 항목을 로드하는 데 실패했습니다.",
"customHoursPlaceholder": "액세스 만료까지 시간"
},
"collab": {
"createRoom": "새로운 방",
"roomName": "객실 이름",
"persistentRoom": "지속적인 방",
"persistentRoomHint": "회의가 끝난 후에도 지정된 회의실 목록에 남아 재사용할 수 있습니다.",
"noRooms": "아직 회의실이 없습니다. 회의를 시작하려면 회의실을 만드세요.",
"openRoom": "열려 있는",
"endRoom": "회의 종료",
"leaveRoom": "떠나다",
"deleteMember": "제거하다",
"removeMember": "{{name}}를 제거하세요",
"removeMemberTitle": "이 회원을 삭제하시겠습니까?",
"removeMemberDescription": "{{name}} 사용자는 즉시 이 방에 대한 접근 권한을 잃게 됩니다. 활성화된 SSH 뷰어는 모두 연결이 끊어집니다.",
"invite": "초대하다",
"inviteTitle": "회원 초대",
"members": "회원들",
"membersPanel": "회의실 구성원 및 제어 요청",
"membersWithCount": "멤버({{count}})",
"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": "이 회의 탭이 만료되었습니다. 회의 패널에서 회의실을 다시 여세요.",
"requestControl": "요청 제어",
"cancelControlRequest": "취소 요청",
"controlQueue": "제어 요청",
"noControlRequests": "보류 중인 요청이 없습니다.",
"grantControlTo": "{{name}}에 제어권을 넘겨주세요",
"dismissControlRequest": "{{name}}의 제어 요청을 기각합니다.",
"releaseControl": "릴리스 컨트롤",
"grantControl": "제어권을 넘겨주세요",
"revokeControl": "주도권을 되찾으세요",
"controlRequestedBy": "{{name}} 제어권을 요청했습니다.",
"grant": "승인하다",
"controlBadge": "통제하에",
"youArePresenting": "다른 탭에서 프레젠테이션을 진행하고 계십니다. 프레젠테이션을 중지하고 다음 사람에게 넘겨주세요.",
"created": "방이 생성되었습니다",
"invited": "회원 초대",
"roomTab": "회의"
},
"nav": {
"collab": "회의"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "세션 공유 허용",
"description": "라이브 터미널, RDP, VNC 및 Telnet 세션을 인스턴스 전체에서 공유할 수 있도록 허용합니다. 이 기능을 비활성화하면 호스트별 공유 설정이 모두 무시됩니다."
}
},
"host": {
"allowSessionSharing": {
"label": "세션 공유 허용",
"description": "이 호스트에서 진행되는 라이브 세션을 링크 또는 다른 사용자와 공유하세요."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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",
"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.",
"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}})",
"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.",
"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"
},
"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."
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Kobler til delt økt...",
"linkInvalid": "Denne delingslenken er ugyldig, utløpt eller har blitt tilbakekalt",
"rateLimited": "For mange forsøk, prøv igjen snart",
"sessionEnded": "Denne økten er avsluttet",
"readOnlyBadge": "Kun visning",
"ownerLabel": "Vertseier"
},
"modalTitle": "Del økt",
"shareButton": "Dele",
"notReadyToShare": "Økten er ikke klar til deling ennå",
"modeTab": {
"link": "Lenke",
"user": "Bruker"
},
"linkModeDescription": "Alle med denne lenken kan bli med, ingen konto kreves.",
"userModeDescription": "Del med en bestemt bruker som allerede har tilgang til denne verten. Hvis de ikke har tilgang ennå, del verten med dem først eller bruk en lenke i stedet. Når økten er delt, vises den i deres Tilkoblinger-fane.",
"permissionLevel": {
"label": "Tillatelsesnivå",
"readOnly": "Skrivebeskyttet",
"readOnlyDescription": "Kan se økten direkte, men kan ikke skrive eller samhandle.",
"readWrite": "Lese-skrive",
"readWriteDescription": "Kan skrive og samhandle med økten akkurat som eieren."
},
"expiryLabel": "Utløpsdato for lenke",
"createLinkButton": "Opprett lenke",
"createShareButton": "Del med brukeren",
"searchUsersPlaceholder": "Søk etter brukere...",
"noUsersFound": "Ingen brukere funnet",
"linkCreated": "Delingslenke opprettet",
"linkCopied": "Lenke kopiert til utklippstavlen",
"copyLink": "Kopier lenke",
"shareCreated": "Økten er delt. Den vil vises i Tilkoblinger-fanen deres.",
"shareFailed": "Kunne ikke opprette delingen",
"userLacksHostAccess": "Den brukeren har ikke tilgang til denne verten ennå. Del verten med dem først, eller bruk en lenke i stedet.",
"activeShares": "Aktive aksjer",
"noActiveShares": "Ingen aktive delinger for denne økten",
"revoke": "Tilbakekalling",
"revokeConfirmTitle": "Tilbakekalle denne delingen?",
"revokeConfirmDescription": "Alle som bruker denne delingen vil miste tilgangen umiddelbart.",
"revoked": "Deling tilbakekalt",
"revokeFailed": "Kunne ikke tilbakekalle delingen",
"joinCount": "{{count}} bli med",
"joinCount_other": "{{count}} blir med",
"expiresAt": "Utløper {{date}}",
"linkShareBadge": "Lenke",
"userShareBadge": "Bruker: {{username}}",
"loadSharesFailed": "Kunne ikke laste inn aktive delinger",
"customHoursPlaceholder": "Timer til tilgangen utløper"
},
"collab": {
"createRoom": "Nytt rom",
"roomName": "Romnavn",
"persistentRoom": "Permanent rom",
"persistentRoomHint": "Permanente rom forblir oppført etter at møtet er avsluttet og kan brukes om igjen.",
"noRooms": "Ingen rom ennå. Opprett et for å starte et møte.",
"openRoom": "Åpne",
"endRoom": "Avslutt møtet",
"leaveRoom": "Forlate",
"deleteMember": "Fjerne",
"removeMember": "Fjern {{name}}",
"removeMemberTitle": "Fjerne dette medlemmet?",
"removeMemberDescription": "{{name}} mister tilgangen til dette rommet umiddelbart. Alle aktive SSH-lesere blir frakoblet.",
"invite": "Invitere",
"inviteTitle": "Inviter medlemmer",
"members": "Medlemmer",
"membersPanel": "Rommedlemmer og kontrollforespørsler",
"membersWithCount": "Medlemmer ({{count}})",
"online": "på nett",
"offline": "frakoblet",
"you": "(du)",
"hostBadge": "Vert",
"presenterBadge": "Presenterer",
"present": "Nåværende",
"presentTitle": "Innta scenen",
"presentHost": "Vert",
"searchHosts": "Søk etter verter",
"noHostsFound": "Ingen tilgjengelige verter funnet.",
"presentProtocol": "Protokoll",
"stopPresenting": "Slutt å presentere",
"takeOver": "Ta over",
"takeOverConfirmTitle": "Erstatte den nåværende presentasjonen?",
"takeOverDescription": "{{name}} presenterer nå. Å ta over vil koble alle fra det stadiet.",
"emptyStage": "Ingen presenterer. Gå på scenen for å dele en økt.",
"stageLoading": "Kobler til scenen...",
"stageEnded": "Presentasjonen ble avsluttet",
"roomEnded": "Dette møtet er avsluttet",
"presenterLabel": "{{name}} presenterer",
"guestLink": "Gjestelenke",
"guestLinkOn": "Gjestelenken er på. Alle med lenken kan se scenen.",
"guestLinkOff": "Gjestelenken er av.",
"copyLink": "Kopier lenke",
"rotateLink": "Roter lenken",
"disableLink": "Deaktiver lenken",
"rotateLinkConfirmTitle": "Roter gjestelenken?",
"rotateLinkDescription": "Den nåværende lenken vil slutte å virke umiddelbart. Tilkoblede SSH-gjester vil bli frakoblet.",
"disableLinkConfirmTitle": "Deaktivere gjestelenken?",
"disableLinkDescription": "Anonyme SSH-lesere vil bli frakoblet umiddelbart, og den nåværende koblingen vil slutte å fungere.",
"linkCopied": "Lenken er kopiert",
"roles": "Roller",
"users": "Brukere",
"searchUsers": "Søk etter brukere",
"endConfirmTitle": "Avslutte dette møtet?",
"endDescription": "{{name}} vil avsluttes for alle og kan ikke åpnes igjen.",
"endPersistentDescription": "Den aktive presentasjonen i {{name}} stopper. Rommet er fortsatt tilgjengelig for gjenbruk.",
"deleteRoom": "Slett rom",
"deleteConfirmTitle": "Slette dette rommet?",
"deleteDescription": "{{name}} vil bli permanent slettet for alle og kan ikke angres.",
"invitedTo": "Du ble invitert til «{{name}}»",
"guest": {
"title": "Møte",
"waiting": "Venter på programlederen...",
"linkInvalid": "Denne gjestelenken er ugyldig, eller møtet er avsluttet"
},
"reopenFromPanel": "Denne møtefanen er utløpt. Åpne rommet på nytt fra Møter-panelet.",
"requestControl": "Be om kontroll",
"cancelControlRequest": "Avbryt forespørsel",
"controlQueue": "Kontrollforespørsler",
"noControlRequests": "Ingen ventende forespørsler.",
"grantControlTo": "Gi kontroll til {{name}}",
"dismissControlRequest": "Avvis kontrollforespørselen til {{name}}",
"releaseControl": "Slipp kontrollen",
"grantControl": "Gi kontroll",
"revokeControl": "Ta tilbake kontrollen",
"controlRequestedBy": "{{name}} ba om kontroll",
"grant": "Stipend",
"controlBadge": "I kontroll",
"youArePresenting": "Du presenterer i en annen fane. Stopp presentasjonen for å gi fra deg scenen.",
"created": "Rommet er opprettet",
"invited": "Medlemmer invitert",
"roomTab": "Møte"
},
"nav": {
"collab": "Møter"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Tillat øktdeling",
"description": "Tillat deling av Live Terminal-, RDP-, VNC- og Telnet-økter på tvers av hele instansen. Overstyrer alle delingsalternativer per vert når de er deaktivert."
}
},
"host": {
"allowSessionSharing": {
"label": "Tillat øktdeling",
"description": "La direktesendte økter på denne verten deles via lenke eller med andre brukere"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Właściciel gospodarza"
},
"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",
"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ń",
"customHoursPlaceholder": "Godziny do wygaśnięcia dostępu"
},
"collab": {
"createRoom": "Nowy pokój",
"roomName": "Nazwa pokoju",
"persistentRoom": "Trwały pokój",
"persistentRoomHint": "Trwałe pokoje pozostają na liście po zakończeniu spotkania i można je ponownie wykorzystać.",
"noRooms": "Brak pokoi. Utwórz jeden, aby rozpocząć spotkanie.",
"openRoom": "Otwarte",
"endRoom": "Zakończ spotkanie",
"leaveRoom": "Wyjechać",
"deleteMember": "Usunąć",
"removeMember": "Usuń {{name}}",
"removeMemberTitle": "Usunąć tego członka?",
"removeMemberDescription": "{{name}} natychmiast utraci dostęp do tego pokoju. Każdy aktywny użytkownik SSH zostanie rozłączony.",
"invite": "Zapraszać",
"inviteTitle": "Zaproś członków",
"members": "Członkowie",
"membersPanel": "Członkowie pokoju i prośby o kontrolę",
"membersWithCount": "Członkowie ({{count}})",
"online": "w sieci",
"offline": "nieaktywny",
"you": "(Ty)",
"hostBadge": "Gospodarz",
"presenterBadge": "Przedstawianie",
"present": "Obecny",
"presentTitle": "Wejdź na scenę",
"presentHost": "Gospodarz",
"searchHosts": "Wyszukaj gospodarzy",
"noHostsFound": "Nie znaleziono dostępnych hostów.",
"presentProtocol": "Protokół",
"stopPresenting": "Zatrzymaj prezentację",
"takeOver": "Przejąć",
"takeOverConfirmTitle": "Zastąpić bieżącą prezentację?",
"takeOverDescription": "{{name}} jest teraz prezentowany. Przejęcie kontroli spowoduje odłączenie wszystkich od tej sceny.",
"emptyStage": "Nikt nie prowadzi prezentacji. Wejdź na scenę i podziel się swoją sesją.",
"stageLoading": "Łączę się ze sceną...",
"stageEnded": "Prezentacja dobiegła końca",
"roomEnded": "To spotkanie dobiegło końca",
"presenterLabel": "{{name}} prezentuje",
"guestLink": "Link gościnny",
"guestLinkOn": "Link dla gości jest włączony. Każdy, kto ma link, może obejrzeć scenę.",
"guestLinkOff": "Link dla gości jest wyłączony.",
"copyLink": "Kopiuj link",
"rotateLink": "Obróć link",
"disableLink": "Wyłącz łącze",
"rotateLinkConfirmTitle": "Obrócić link gościa?",
"rotateLinkDescription": "Obecny link natychmiast przestanie działać. Połączeni goście SSH zostaną rozłączeni.",
"disableLinkConfirmTitle": "Wyłączyć link gościa?",
"disableLinkDescription": "Anonimowi użytkownicy SSH zostaną natychmiast rozłączeni, a bieżące łącze przestanie działać.",
"linkCopied": "Link skopiowany",
"roles": "Role",
"users": "Użytkownicy",
"searchUsers": "Wyszukaj użytkowników",
"endConfirmTitle": "Zakończyć to spotkanie?",
"endDescription": "{{name}} zakończy się dla wszystkich i nie będzie można go ponownie otworzyć.",
"endPersistentDescription": "Aktywna prezentacja w {{name}} zostanie zatrzymana. Pokój pozostaje dostępny do ponownego użycia.",
"deleteRoom": "Usuń pokój",
"deleteConfirmTitle": "Usunąć ten pokój?",
"deleteDescription": "{{name}} zostanie trwale usunięty u wszystkich i nie będzie można tego cofnąć.",
"invitedTo": "Zostałeś zaproszony do „{{name}}”",
"guest": {
"title": "Spotkanie",
"waiting": "Czekam na prezentera...",
"linkInvalid": "Ten link dla gościa jest nieprawidłowy lub spotkanie się zakończyło"
},
"reopenFromPanel": "Ta karta spotkania wygasła. Otwórz ponownie pokój z panelu Spotkania.",
"requestControl": "Żądanie kontroli",
"cancelControlRequest": "Anuluj prośbę",
"controlQueue": "Żądania kontrolne",
"noControlRequests": "Brak oczekujących żądań.",
"grantControlTo": "Przekaż kontrolę {{name}}",
"dismissControlRequest": "Odrzuć żądanie kontroli {{name}}",
"releaseControl": "Kontrola wydania",
"grantControl": "Oddaj kontrolę",
"revokeControl": "Odzyskaj kontrolę",
"controlRequestedBy": "{{name}} poprosił o kontrolę",
"grant": "Dotacja",
"controlBadge": "Pod kontrolą",
"youArePresenting": "Prezentujesz na innej karcie. Zatrzymaj prezentację, aby przekazać scenę.",
"created": "Pokój utworzony",
"invited": "Zaproszeni członkowie",
"roomTab": "Spotkanie"
},
"nav": {
"collab": "Spotkania"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Conectando à sessão compartilhada...",
"linkInvalid": "Este link de compartilhamento é inválido, expirou ou foi revogado.",
"rateLimited": "Muitas tentativas foram feitas, tente novamente em breve.",
"sessionEnded": "Esta sessão terminou.",
"readOnlyBadge": "Somente visualização",
"ownerLabel": "Proprietário do anfitrião"
},
"modalTitle": "Sessão de compartilhamento",
"shareButton": "Compartilhar",
"notReadyToShare": "A sessão ainda não está pronta para ser compartilhada.",
"modeTab": {
"link": "Link",
"user": "Usuário"
},
"linkModeDescription": "Qualquer pessoa com este link pode participar, sem necessidade de criar uma conta.",
"userModeDescription": "Compartilhe com um usuário específico que já tenha acesso a este host. Caso ele ainda não tenha acesso, compartilhe o host com ele primeiro ou use um link. Após o compartilhamento, a sessão aparecerá na aba Conexões do usuário.",
"permissionLevel": {
"label": "Nível de permissão",
"readOnly": "Somente leitura",
"readOnlyDescription": "É possível assistir à sessão ao vivo, mas não é possível digitar ou interagir.",
"readWrite": "Ler e escrever",
"readWriteDescription": "Pode digitar e interagir com a sessão da mesma forma que o proprietário."
},
"expiryLabel": "Expiração do link",
"createLinkButton": "Criar link",
"createShareButton": "Compartilhar com o usuário",
"searchUsersPlaceholder": "Pesquisar usuários...",
"noUsersFound": "Nenhum usuário encontrado",
"linkCreated": "Compartilhar link criado",
"linkCopied": "Link copiado para a área de transferência",
"copyLink": "Copiar link",
"shareCreated": "Sessão compartilhada. Ela aparecerá na aba Conexões deles.",
"shareFailed": "Falha ao criar compartilhamento",
"userLacksHostAccess": "Esse usuário ainda não tem acesso a este host. Compartilhe o host com ele primeiro ou use um link.",
"activeShares": "Ações ativas",
"noActiveShares": "Nenhuma ação ativa nesta sessão.",
"revoke": "Revogar",
"revokeConfirmTitle": "Revogar essa participação?",
"revokeConfirmDescription": "Qualquer pessoa que usar este compartilhamento perderá o acesso imediatamente.",
"revoked": "Ações revogadas",
"revokeFailed": "Falha ao revogar a participação",
"joinCount": "{{count}} junte-se",
"joinCount_other": "{{count}} entra",
"expiresAt": "Expira em {{date}}",
"linkShareBadge": "Link",
"userShareBadge": "Usuário: {{username}}",
"loadSharesFailed": "Falha ao carregar compartilhamentos ativos",
"customHoursPlaceholder": "Horas restantes até o acesso expirar"
},
"collab": {
"createRoom": "Quarto novo",
"roomName": "Nome do quarto",
"persistentRoom": "Sala persistente",
"persistentRoomHint": "As salas permanentes permanecem listadas após o término da reunião e podem ser reutilizadas.",
"noRooms": "Ainda não há salas. Crie uma para iniciar uma reunião.",
"openRoom": "Abrir",
"endRoom": "Encerramento da reunião",
"leaveRoom": "Deixar",
"deleteMember": "Remover",
"removeMember": "Remover {{name}}",
"removeMemberTitle": "Remover este membro?",
"removeMemberDescription": "{{name}} perderá o acesso a esta sala imediatamente. Qualquer visualizador SSH ativo será desconectado.",
"invite": "Convidar",
"inviteTitle": "Convide membros",
"members": "Membros",
"membersPanel": "Membros da sala e solicitações de controle",
"membersWithCount": "Membros ({{count}})",
"online": "on-line",
"offline": "offline",
"you": "(você)",
"hostBadge": "Hospedar",
"presenterBadge": "Apresentando",
"present": "Presente",
"presentTitle": "Suba ao palco",
"presentHost": "Hospedar",
"searchHosts": "Pesquisar anfitriões",
"noHostsFound": "Nenhum host disponível encontrado.",
"presentProtocol": "Protocolo",
"stopPresenting": "Pare de apresentar",
"takeOver": "Assumir",
"takeOverConfirmTitle": "Substituir a apresentação atual?",
"takeOverDescription": "{{name}} está apresentando agora. Assumir o controle desconectará todos desse palco.",
"emptyStage": "Ninguém está apresentando. Suba ao palco para compartilhar uma sessão.",
"stageLoading": "Conectando-se ao palco...",
"stageEnded": "A apresentação terminou.",
"roomEnded": "Esta reunião terminou.",
"presenterLabel": "{{name}} está apresentando",
"guestLink": "Link para convidado",
"guestLinkOn": "O link para convidados está ativado. Qualquer pessoa com o link pode assistir à apresentação.",
"guestLinkOff": "O link para visitantes está desativado.",
"copyLink": "Copiar link",
"rotateLink": "Rotacionar link",
"disableLink": "Desativar link",
"rotateLinkConfirmTitle": "Girar o link do convidado?",
"rotateLinkDescription": "A conexão atual deixará de funcionar imediatamente. Os dispositivos convidados SSH conectados serão desconectados.",
"disableLinkConfirmTitle": "Desativar o link de convidado?",
"disableLinkDescription": "Os visualizadores SSH anônimos serão desconectados imediatamente e a conexão atual deixará de funcionar.",
"linkCopied": "Link copiado",
"roles": "Funções",
"users": "Usuários",
"searchUsers": "Usuários da pesquisa",
"endConfirmTitle": "Encerrar esta reunião?",
"endDescription": "{{name}} será encerrado para todos e não poderá ser reaberto.",
"endPersistentDescription": "A apresentação ativa em {{name}} será interrompida. A sala permanece disponível para reutilização.",
"deleteRoom": "Excluir sala",
"deleteConfirmTitle": "Apagar esta sala?",
"deleteDescription": "{{name}} será excluído permanentemente para todos e não poderá ser desfeito.",
"invitedTo": "Você foi convidado para \"{{name}}\"",
"guest": {
"title": "Reunião",
"waiting": "Aguardando o apresentador...",
"linkInvalid": "Este link de convidado é inválido ou a reunião já terminou."
},
"reopenFromPanel": "Esta aba da reunião expirou. Reabra a sala no painel Reuniões.",
"requestControl": "Controle de solicitação",
"cancelControlRequest": "Cancelar solicitação",
"controlQueue": "Solicitações de controle",
"noControlRequests": "Não há solicitações pendentes.",
"grantControlTo": "Dê o controle para {{name}}",
"dismissControlRequest": "Rejeitar solicitação de controle de {{name}}",
"releaseControl": "Liberar controle",
"grantControl": "Dê o controle",
"revokeControl": "Retome o controle",
"controlRequestedBy": "{{name}} solicitou o controle",
"grant": "Conceder",
"controlBadge": "No controle",
"youArePresenting": "Você está apresentando em outra aba. Pare de apresentar para passar a palavra.",
"created": "Quarto criado",
"invited": "Membros convidados",
"roomTab": "Reunião"
},
"nav": {
"collab": "Reuniões"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Permitir compartilhamento de sessão",
"description": "Permite que sessões de terminal ao vivo, RDP, VNC e Telnet sejam compartilhadas em toda a instância. Substitui todas as configurações de compartilhamento por host quando desativada."
}
},
"host": {
"allowSessionSharing": {
"label": "Permitir compartilhamento de sessão",
"description": "Permitir que as sessões ao vivo neste servidor sejam compartilhadas por meio de link ou com outros usuários."
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Ligar à sessão partilhada...",
"linkInvalid": "Este link de partilha é inválido, expirou ou foi revogado.",
"rateLimited": "Muitas tentativas foram feitas, tente novamente em breve.",
"sessionEnded": "Esta sessão terminou.",
"readOnlyBadge": "Somente visualização",
"ownerLabel": "Proprietário do anfitrião"
},
"modalTitle": "Sessão de partilha",
"shareButton": "Partilhar",
"notReadyToShare": "A sessão ainda não está pronta para ser partilhada.",
"modeTab": {
"link": "Ligação",
"user": "Usuário"
},
"linkModeDescription": "Qualquer pessoa com este link pode participar, sem necessidade de criar conta.",
"userModeDescription": "Partilhe com um utilizador específico que já tenha acesso a este host. Caso ainda não tenha acesso, partilhe primeiro o host com ele ou utilize um link. Após a partilha, a sessão aparecerá no separador Conexões do utilizador.",
"permissionLevel": {
"label": "Nível de permissão",
"readOnly": "Somente leitura",
"readOnlyDescription": "É possível assistir à sessão em direto, mas não é possível digitar ou interagir.",
"readWrite": "Ler e escrever",
"readWriteDescription": "Pode digitar e interagir com a sessão da mesma forma que o proprietário."
},
"expiryLabel": "Expiração do link",
"createLinkButton": "Criar link",
"createShareButton": "Partilhar com o usuário",
"searchUsersPlaceholder": "Pesquisar utilizadores...",
"noUsersFound": "Nenhum utilizador encontrado",
"linkCreated": "Partilhar link criado",
"linkCopied": "Ligação copiada para a área de transferência",
"copyLink": "Copiar link",
"shareCreated": "Sessão partilhada. Ela aparecerá no separador Conexões deles.",
"shareFailed": "Falha ao criar partilha",
"userLacksHostAccess": "Esse utilizador ainda não tem acesso a este host. Partilhe o host com ele primeiro ou use um link.",
"activeShares": "Ações ativas",
"noActiveShares": "Sem ação ativa nesta sessão.",
"revoke": "Revogar",
"revokeConfirmTitle": "Revogar essa participação?",
"revokeConfirmDescription": "Qualquer pessoa que utilize esta partilha perderá o acesso imediatamente.",
"revoked": "Ações revogadas",
"revokeFailed": "Falha ao revogar a participação",
"joinCount": "{{count}} junte-se",
"joinCount_other": "{{count}} entra",
"expiresAt": "Expira em {{date}}",
"linkShareBadge": "Ligação",
"userShareBadge": "Utilizador: {{username}}",
"loadSharesFailed": "Falha ao carregar partilhas ativas",
"customHoursPlaceholder": "Horas até à expiração do acesso"
},
"collab": {
"createRoom": "Quarto novo",
"roomName": "Nome do quarto",
"persistentRoom": "Sala persistente",
"persistentRoomHint": "As salas permanentes permanecem listadas após o término da reunião e podem ser reutilizadas.",
"noRooms": "Ainda não há salas. Crie uma para iniciar uma reunião.",
"openRoom": "Aberto",
"endRoom": "Encerramento da reunião",
"leaveRoom": "Ausência",
"deleteMember": "Remover",
"removeMember": "Remover {{name}}",
"removeMemberTitle": "Remover este membro?",
"removeMemberDescription": "{{name}} perderá o acesso a esta sala imediatamente. Qualquer visualizador SSH ativo será desligado.",
"invite": "Convidar",
"inviteTitle": "Convide os membros",
"members": "Membros",
"membersPanel": "Membros da sala e pedidos de controlo",
"membersWithCount": "Membros ({{count}})",
"online": "online",
"offline": "offline",
"you": "(tu)",
"hostBadge": "Anfitrião",
"presenterBadge": "Apresentando",
"present": "Presente",
"presentTitle": "Suba ao palco",
"presentHost": "Anfitrião",
"searchHosts": "Pesquisar anfitriões",
"noHostsFound": "Nenhum host disponível encontrado.",
"presentProtocol": "Protocolo",
"stopPresenting": "Pare de apresentar",
"takeOver": "Assumir",
"takeOverConfirmTitle": "Substituir a apresentação atual?",
"takeOverDescription": "{{name}} está a apresentar agora. Assumir o controlo desconectará todos desse palco.",
"emptyStage": "Ninguém está a apresentar. Suba ao palco para partilhar uma sessão.",
"stageLoading": "Ligar-se ao palco...",
"stageEnded": "A apresentação terminou.",
"roomEnded": "Esta reunião terminou.",
"presenterLabel": "{{name}} está a apresentar",
"guestLink": "Link para convidado",
"guestLinkOn": "O link para convidados está ativado. Qualquer pessoa com o link pode assistir à apresentação.",
"guestLinkOff": "O link para os visitantes está desativado.",
"copyLink": "Copiar link",
"rotateLink": "Rotacionar ligação",
"disableLink": "Desativar ligação",
"rotateLinkConfirmTitle": "Rodar o link do convidado?",
"rotateLinkDescription": "A ligação atual deixará de funcionar imediatamente. Os dispositivos convidados SSH ligados serão desligados.",
"disableLinkConfirmTitle": "Desativar o link de convidado?",
"disableLinkDescription": "Os visualizadores SSH anónimos serão imediatamente desligados e a ligação atual deixará de funcionar.",
"linkCopied": "Link copiado",
"roles": "Funções",
"users": "Usuários",
"searchUsers": "Os utilizadores da pesquisa",
"endConfirmTitle": "Encerrar esta reunião?",
"endDescription": "{{name}} será encerrado para todos e não poderá ser reaberto.",
"endPersistentDescription": "A apresentação ativa em {{name}} será interrompida. A sala continua disponível para reutilização.",
"deleteRoom": "Apagar sala",
"deleteConfirmTitle": "Apagar esta sala?",
"deleteDescription": "{{name}} será eliminado permanentemente para todos e não poderá ser anulado.",
"invitedTo": "Foi convidado para \"{{name}}\"",
"guest": {
"title": "Reunião",
"waiting": "À espera do apresentador...",
"linkInvalid": "Este link de convidado é inválido ou a reunião já terminou."
},
"reopenFromPanel": "Esta aba da reunião expirou. Volte a abrir a sala no painel Reuniões.",
"requestControl": "Controle de solicitação",
"cancelControlRequest": "Cancelar pedido",
"controlQueue": "Solicitações de controlo",
"noControlRequests": "Não há pedidos pendentes.",
"grantControlTo": "Dê o controlo a {{name}}",
"dismissControlRequest": "Rejeitar pedido de controlo de {{name}}",
"releaseControl": "Libertar controle",
"grantControl": "Dê o controlo",
"revokeControl": "Retome o comando",
"controlRequestedBy": "{{name}} solicitou o controlo",
"grant": "Subvenção",
"controlBadge": "No controlo",
"youArePresenting": "Está a apresentar em outra aba. Pare de apresentar para passar a palavra.",
"created": "Quarto criado",
"invited": "Membros convidados",
"roomTab": "Reunião"
},
"nav": {
"collab": "Reuniões"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Permitir partilha de sessão",
"description": "Permite que as sessões de terminal ao vivo, RDP, VNC e Telnet sejam partilhadas em toda a instância. Substitui todas as definições de partilha por host quando desativada."
}
},
"host": {
"allowSessionSharing": {
"label": "Permitir partilha de sessão",
"description": "Permitir que as sessões em direto neste servidor sejam partilhadas através de link ou com outros utilizadores."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Doar 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",
"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",
"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}})",
"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.",
"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"
},
"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"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Ссылка для обмена создана",
"linkCopied": "Ссылка скопирована в буфер обмена",
"copyLink": "Скопировать ссылку",
"shareCreated": "Сессия была проведена совместно. Она отобразится на вкладке «Подключения».",
"shareFailed": "Не удалось создать общий ресурс.",
"userLacksHostAccess": "У этого пользователя пока нет доступа к данному хосту. Сначала предоставьте ему доступ к хосту или используйте ссылку.",
"activeShares": "Активные акции",
"noActiveShares": "В текущей торговой сессии нет активных акций.",
"revoke": "Отменить",
"revokeConfirmTitle": "Отозвать эту публикацию?",
"revokeConfirmDescription": "Любой, кто воспользуется этой ссылкой, немедленно потеряет к ней доступ.",
"revoked": "Акция аннулирована",
"revokeFailed": "Не удалось отозвать акцию.",
"joinCount": "{{count}} присоединиться",
"joinCount_other": "{{count}} присоединяется",
"expiresAt": "Срок действия истекает {{date}}",
"linkShareBadge": "Связь",
"userShareBadge": "Пользователь: {{username}}",
"loadSharesFailed": "Не удалось загрузить активные акции.",
"customHoursPlaceholder": "Часов до окончания срока доступа"
},
"collab": {
"createRoom": "Новая комната",
"roomName": "Название комнаты",
"persistentRoom": "Постоянное помещение",
"persistentRoomHint": "Список свободных комнат сохраняется и после окончания встречи, и их можно использовать повторно.",
"noRooms": "Комнат пока нет. Создайте комнату, чтобы начать встречу.",
"openRoom": "Открыть",
"endRoom": "Завершение встречи",
"leaveRoom": "Оставлять",
"deleteMember": "Удалять",
"removeMember": "Удалить {{name}}",
"removeMemberTitle": "Удалить этого участника?",
"removeMemberDescription": "{{name}} немедленно потеряет доступ к этой комнате. Любой активный SSH-просмотрщик будет отключен.",
"invite": "Приглашать",
"inviteTitle": "Пригласить участников",
"members": "Члены",
"membersPanel": "Участники комнаты и запросы на управление",
"membersWithCount": "Участники ({{count}})",
"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": "Срок действия этой вкладки «Встречи» истек. Откройте комнату заново на панели «Встречи».",
"requestControl": "Управление запросами",
"cancelControlRequest": "Отменить запрос",
"controlQueue": "Запросы на управление",
"noControlRequests": "Нет ожидающих обработки запросов.",
"grantControlTo": "Передайте управление {{name}}",
"dismissControlRequest": "Отклонить запрос на управление от {{name}}",
"releaseControl": "Управление освобождением",
"grantControl": "Передайте контроль",
"revokeControl": "Верните себе контроль.",
"controlRequestedBy": "{{name}} запросил управление",
"grant": "Грант",
"controlBadge": "Под контролем",
"youArePresenting": "Вы ведёте презентацию в другой вкладке. Прекратите презентацию, чтобы передать слово другому пользователю.",
"created": "Комната создана",
"invited": "Приглашены члены",
"roomTab": "Встреча"
},
"nav": {
"collab": "Встречи"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Разрешить совместное использование сеанса",
"description": "Разрешить общий доступ к сеансам Live Terminal, RDP, VNC и Telnet для всего экземпляра. При отключении этой функции отменяет все настройки общего доступа для отдельных хостов."
}
},
"host": {
"allowSessionSharing": {
"label": "Разрешить совместное использование сеанса",
"description": "Разрешите делиться записями онлайн-сессий на этом хостинге по ссылке или с другими пользователями."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Линк за дељење је направљен",
"linkCopied": "Линк је копиран у међуспремник",
"copyLink": "Копирај линк",
"shareCreated": "Сесија је дељена. Појавиће се на њиховој картици „Везе“.",
"shareFailed": "Није успело креирање дељења",
"userLacksHostAccess": "Тај корисник још увек нема приступ овом хосту. Прво поделите хост са њим или користите линк.",
"activeShares": "Активне акције",
"noActiveShares": "Нема активних дељења за ову сесију",
"revoke": "Поништи",
"revokeConfirmTitle": "Да ли желите да опозовете ово дељење?",
"revokeConfirmDescription": "Свако ко користи ово дељење ће одмах изгубити приступ.",
"revoked": "Дељење опозвано",
"revokeFailed": "Опозивање дељења није успело",
"joinCount": "{{count}} придружи се",
"joinCount_other": "{{count}} придруживања",
"expiresAt": "Истиче {{date}}",
"linkShareBadge": "Линк",
"userShareBadge": "Корисник: {{username}}",
"loadSharesFailed": "Учитавање активних дељења није успело",
"customHoursPlaceholder": "Сати до истека приступа"
},
"collab": {
"createRoom": "Нова соба",
"roomName": "Назив собе",
"persistentRoom": "Трајна соба",
"persistentRoomHint": "Трајне собе остају наведене након завршетка састанка и могу се поново користити.",
"noRooms": "Још нема соба. Направите једну да бисте започели састанак.",
"openRoom": "Отворено",
"endRoom": "Заврши састанак",
"leaveRoom": "Одиђи",
"deleteMember": "Уклони",
"removeMember": "Уклони {{name}}",
"removeMemberTitle": "Уклонити овог члана?",
"removeMemberDescription": "{{name}} ће одмах изгубити приступ овој соби. Сваки активни SSH гледалац ће бити искључен.",
"invite": "Позови",
"inviteTitle": "Позовите чланове",
"members": "Чланови",
"membersPanel": "Чланови собе и захтеви за контролу",
"membersWithCount": "Чланови ({{count}})",
"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": "Ова картица састанка је истекла. Поново отворите просторију из панела Састанци.",
"requestControl": "Захтевај контролу",
"cancelControlRequest": "Откажи захтев",
"controlQueue": "Захтеви за контролу",
"noControlRequests": "Нема захтева на чекању.",
"grantControlTo": "Дајте контролу кориснику {{name}}",
"dismissControlRequest": "Одбаци захтев за контролу корисника {{name}}",
"releaseControl": "Контрола ослобађања",
"grantControl": "Дајте контролу",
"revokeControl": "Преузмите контролу назад",
"controlRequestedBy": "{{name}} је затражио контролу",
"grant": "Грант",
"controlBadge": "Под контролом",
"youArePresenting": "Презентујете у другој картици. Прекините презентацију да бисте предали сцену.",
"created": "Соба је направљена",
"invited": "Чланови позвани",
"roomTab": "Састанак"
},
"nav": {
"collab": "Састанци"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Дозволи дељење сесије",
"description": "Дозвољава дељење сесија уживо терминала, RDP, VNC и Telnet сесија на нивоу целе инстанце. Замењује сваки прекидач за дељење по хосту када је онемогућен."
}
},
"host": {
"allowSessionSharing": {
"label": "Дозволи дељење сесије",
"description": "Дозволите дељење сесија уживо на овом хосту путем линка или са другим корисницима"
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Ansluter till delad session...",
"linkInvalid": "Den här delningslänken är ogiltig, har löpt ut eller har återkallats",
"rateLimited": "För många försök, försök igen snart",
"sessionEnded": "Den här sessionen är avslutad",
"readOnlyBadge": "Endast visning",
"ownerLabel": "Värdägare"
},
"modalTitle": "Dela session",
"shareButton": "Dela",
"notReadyToShare": "Sessionen är inte redo att delas ännu",
"modeTab": {
"link": "Länk",
"user": "Användare"
},
"linkModeDescription": "Vem som helst med den här länken kan gå med, inget konto krävs.",
"userModeDescription": "Dela med en specifik användare som redan har åtkomst till den här värden. Om de inte har åtkomst ännu, dela värden med dem först eller använd en länk istället. När sessionen har delats visas den på deras flik Anslutningar.",
"permissionLevel": {
"label": "Behörighetsnivå",
"readOnly": "Skrivskyddad",
"readOnlyDescription": "Kan titta på sessionen live men kan inte skriva eller interagera.",
"readWrite": "Läs-skriv",
"readWriteDescription": "Kan skriva och interagera med sessionen precis som ägaren."
},
"expiryLabel": "Länkens utgångsdatum",
"createLinkButton": "Skapa länk",
"createShareButton": "Dela med användaren",
"searchUsersPlaceholder": "Sök användare...",
"noUsersFound": "Inga användare hittades",
"linkCreated": "Delningslänk skapad",
"linkCopied": "Länk kopierad till urklipp",
"copyLink": "Kopiera länk",
"shareCreated": "Sessionen delade. Den kommer att visas på fliken Anslutningar.",
"shareFailed": "Misslyckades med att skapa delningen",
"userLacksHostAccess": "Den användaren har inte åtkomst till den här värden ännu. Dela värden med dem först, eller använd en länk istället.",
"activeShares": "Aktiva aktier",
"noActiveShares": "Inga aktiva delningar för den här sessionen",
"revoke": "Återkalla",
"revokeConfirmTitle": "Återkalla den här delningen?",
"revokeConfirmDescription": "Alla som använder den här delningen kommer att förlora åtkomsten omedelbart.",
"revoked": "Delning återkallad",
"revokeFailed": "Misslyckades med att återkalla delningen",
"joinCount": "{{count}} gå med",
"joinCount_other": "{{count}} ansluter sig",
"expiresAt": "Utgångsdatum {{date}}",
"linkShareBadge": "Länk",
"userShareBadge": "Användare: {{username}}",
"loadSharesFailed": "Misslyckades med att läsa in aktiva delningar",
"customHoursPlaceholder": "Timmar tills åtkomsten upphör"
},
"collab": {
"createRoom": "Nytt rum",
"roomName": "Rumsnamn",
"persistentRoom": "Permanent rum",
"persistentRoomHint": "Beständiga rum finns kvar i listan efter att mötet är slut och kan återanvändas.",
"noRooms": "Inga rum ännu. Skapa ett för att starta ett möte.",
"openRoom": "Öppna",
"endRoom": "Avsluta mötet",
"leaveRoom": "Lämna",
"deleteMember": "Ta bort",
"removeMember": "Ta bort {{name}}",
"removeMemberTitle": "Ta bort den här medlemmen?",
"removeMemberDescription": "{{name}} kommer att förlora åtkomst till det här rummet omedelbart. Alla aktiva SSH-läsare kommer att kopplas bort.",
"invite": "Bjuda",
"inviteTitle": "Bjud in medlemmar",
"members": "Medlemmar",
"membersPanel": "Rumsmedlemmar och kontrollförfrågningar",
"membersWithCount": "Medlemmar ({{count}})",
"online": "online",
"offline": "off-line",
"you": "(du)",
"hostBadge": "Värd",
"presenterBadge": "Presenterar",
"present": "Presentera",
"presentTitle": "Inta scenen",
"presentHost": "Värd",
"searchHosts": "Sök värdar",
"noHostsFound": "Inga tillgängliga värdar hittades.",
"presentProtocol": "Protokoll",
"stopPresenting": "Sluta presentera",
"takeOver": "Ta över",
"takeOverConfirmTitle": "Ersätta den aktuella presentationen?",
"takeOverDescription": "{{name}} presenterar nu. Att ta över kommer att koppla bort alla från den scenen.",
"emptyStage": "Ingen presenterar. Gå ut på scenen för att dela en session.",
"stageLoading": "Ansluter till scenen...",
"stageEnded": "Presentationen avslutades",
"roomEnded": "Det här mötet har avslutats",
"presenterLabel": "{{name}} presenterar",
"guestLink": "Gästlänk",
"guestLinkOn": "Gästlänken är aktiverad. Alla med länken kan titta på scenen.",
"guestLinkOff": "Gästlänken är avstängd.",
"copyLink": "Kopiera länk",
"rotateLink": "Rotera länken",
"disableLink": "Inaktivera länk",
"rotateLinkConfirmTitle": "Rotera gästlänken?",
"rotateLinkDescription": "Den aktuella länken kommer att sluta fungera omedelbart. Anslutna SSH-gäster kommer att kopplas bort.",
"disableLinkConfirmTitle": "Inaktivera gästlänken?",
"disableLinkDescription": "Anonyma SSH-användare kommer att kopplas bort omedelbart och den aktuella länken kommer att sluta fungera.",
"linkCopied": "Länk kopierad",
"roles": "Roller",
"users": "Användare",
"searchUsers": "Sök användare",
"endConfirmTitle": "Avsluta det här mötet?",
"endDescription": "{{name}} kommer att upphöra för alla och kan inte öppnas igen.",
"endPersistentDescription": "Den aktiva presentationen i {{name}} kommer att stoppas. Rummet är fortfarande tillgängligt för återanvändning.",
"deleteRoom": "Ta bort rum",
"deleteConfirmTitle": "Radera det här rummet?",
"deleteDescription": "{{name}} kommer att raderas permanent för alla och kan inte ångras.",
"invitedTo": "Du blev inbjuden till \"{{name}}\"",
"guest": {
"title": "Möte",
"waiting": "Väntar på programledaren...",
"linkInvalid": "Denna gästlänk är ogiltig eller så har mötet avslutats"
},
"reopenFromPanel": "Den här mötesfliken har upphört att gälla. Öppna rummet igen från panelen Möten.",
"requestControl": "Begäran om kontroll",
"cancelControlRequest": "Avbryt begäran",
"controlQueue": "Kontrollförfrågningar",
"noControlRequests": "Inga väntande förfrågningar.",
"grantControlTo": "Ge kontroll till {{name}}",
"dismissControlRequest": "Avvisa kontrollbegäran från {{name}}",
"releaseControl": "Släppkontroll",
"grantControl": "Ge kontroll",
"revokeControl": "Ta tillbaka kontrollen",
"controlRequestedBy": "{{name}} bad om kontroll",
"grant": "Bevilja",
"controlBadge": "I kontroll",
"youArePresenting": "Du presenterar i en annan flik. Avbryt presentationen för att lämna över scenen.",
"created": "Rum skapat",
"invited": "Medlemmar inbjudna",
"roomTab": "Möte"
},
"nav": {
"collab": "Möten"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Tillåt sessionsdelning",
"description": "Tillåt att liveterminal-, RDP-, VNC- och Telnet-sessioner delas instansövergripande. Åsidosätter alla delningsknappar per värd när de är inaktiverade."
}
},
"host": {
"allowSessionSharing": {
"label": "Tillåt sessionsdelning",
"description": "Tillåt att livesessioner på den här värden delas via länk eller med andra användare"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "สร้างลิงก์สำหรับแชร์แล้ว",
"linkCopied": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว",
"copyLink": "คัดลิงก์",
"shareCreated": "แชร์เซสชันแล้ว เซสชันนั้นจะปรากฏในแท็บการเชื่อมต่อของพวกเขา",
"shareFailed": "ไม่สามารถสร้างการแชร์ได้",
"userLacksHostAccess": "ผู้ใช้รายนั้นยังไม่มีสิทธิ์เข้าถึงโฮสต์นี้ โปรดแชร์โฮสต์ให้พวกเขาก่อน หรือใช้ลิงก์แทน",
"activeShares": "หุ้นที่ใช้งานอยู่",
"noActiveShares": "ไม่มีหุ้นที่เปิดซื้อขายในรอบนี้",
"revoke": "ถอน",
"revokeConfirmTitle": "ต้องการยกเลิกการแชร์นี้หรือไม่?",
"revokeConfirmDescription": "ใครก็ตามที่ใช้ลิงก์นี้จะเสียสิทธิ์การเข้าถึงทันที",
"revoked": "หุ้นถูกเพิกถอน",
"revokeFailed": "ไม่สามารถเพิกถอนหุ้นได้",
"joinCount": "{{count}} เข้าร่วม",
"joinCount_other": "{{count}} เข้าร่วม",
"expiresAt": "หมดอายุ {{date}}",
"linkShareBadge": "ลิงก์",
"userShareBadge": "ผู้ใช้: {{username}}",
"loadSharesFailed": "ไม่สามารถโหลดหุ้นที่ใช้งานอยู่ได้",
"customHoursPlaceholder": "เหลือเวลาอีกกี่ชั่วโมงก่อนที่สิทธิ์การเข้าถึงจะหมดอายุ"
},
"collab": {
"createRoom": "ห้องใหม่",
"roomName": "ชื่อห้อง",
"persistentRoom": "ห้องที่คงอยู่",
"persistentRoomHint": "ห้องประชุมที่ตั้งค่าไว้ถาวรจะยังคงอยู่ในรายการหลังจากสิ้นสุดการประชุมและสามารถนำกลับมาใช้ใหม่ได้",
"noRooms": "ยังไม่มีห้องประชุม สร้างห้องประชุมเพื่อเริ่มการประชุม",
"openRoom": "เปิด",
"endRoom": "สิ้นสุดการประชุม",
"leaveRoom": "ออกจาก",
"deleteMember": "ลบ",
"removeMember": "ลบ {{name}}",
"removeMemberTitle": "ลบสมาชิกนี้ออก?",
"removeMemberDescription": "{{name}} จะเสียสิทธิ์ในการเข้าถึงห้องนี้ทันที โปรแกรมดู SSH ที่ใช้งานอยู่จะถูกตัดการเชื่อมต่อ",
"invite": "เชิญ",
"inviteTitle": "เชิญสมาชิก",
"members": "สมาชิก",
"membersPanel": "สมาชิกห้องและคำขอควบคุม",
"membersWithCount": "สมาชิก ({{count}})",
"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": "แท็บการประชุมนี้หมดอายุแล้ว โปรดเปิดห้องประชุมอีกครั้งจากแผงการประชุม",
"requestControl": "การควบคุมคำขอ",
"cancelControlRequest": "คำขอการยกเลิก",
"controlQueue": "คำขอควบคุม",
"noControlRequests": "ไม่มีคำขอที่รอการดำเนินการ",
"grantControlTo": "มอบการควบคุมให้กับ {{name}}",
"dismissControlRequest": "ยกเลิกคำขอควบคุมของ {{name}}",
"releaseControl": "ปล่อยการควบคุม",
"grantControl": "มอบการควบคุม",
"revokeControl": "ทวงคืนการควบคุม",
"controlRequestedBy": "{{name}} ขอควบคุม",
"grant": "ยินยอม",
"controlBadge": "ควบคุมได้",
"youArePresenting": "คุณกำลังนำเสนออยู่ในแท็บอื่น โปรดหยุดการนำเสนอเพื่อส่งต่อให้ผู้อื่น",
"created": "สร้างห้อง",
"invited": "สมาชิกที่ได้รับเชิญ",
"roomTab": "การประชุม"
},
"nav": {
"collab": "การประชุม"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "อนุญาตการแชร์เซสชัน",
"description": "อนุญาตให้แชร์เซสชันเทอร์มินัลสด, RDP, VNC และ Telnet ทั่วทั้งอินสแตนซ์ การตั้งค่านี้จะแทนที่การตั้งค่าการแชร์ต่อโฮสต์ทั้งหมดเมื่อปิดใช้งานอยู่"
}
},
"host": {
"allowSessionSharing": {
"label": "อนุญาตการแชร์เซสชัน",
"description": "อนุญาตให้แชร์เซสชันสดบนโฮสต์นี้ผ่านลิงก์หรือกับผู้ใช้รายอื่น"
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Paylaşılan oturuma bağlanılıyor...",
"linkInvalid": "Bu paylaşım bağlantısı geçersiz, süresi dolmuş veya iptal edilmiş.",
"rateLimited": "Çok fazla deneme yapıldı, lütfen kısa süre sonra tekrar deneyin.",
"sessionEnded": "Bu oturum sona erdi.",
"readOnlyBadge": "Yalnızca görüntüle",
"ownerLabel": "Ev sahibi"
},
"modalTitle": "Oturumu paylaş",
"shareButton": "Paylaşmak",
"notReadyToShare": "Oturum henüz paylaşılmaya hazır değil.",
"modeTab": {
"link": "Bağlantı",
"user": "Kullanıcı"
},
"linkModeDescription": "Bu bağlantıya sahip herkes katılabilir, hesap gerekmez.",
"userModeDescription": "Bu sunucuya zaten erişimi olan belirli bir kullanıcıyla paylaşın. Henüz erişimleri yoksa, önce sunucuyu onlarla paylaşın veya bunun yerine bir bağlantı kullanın. Paylaşıldıktan sonra, oturum Bağlantılar sekmesinde görünür.",
"permissionLevel": {
"label": "İzin seviyesi",
"readOnly": "salt okunur",
"readOnlyDescription": "Oturumu canlı olarak izleyebilirsiniz ancak yazı yazamaz veya etkileşimde bulunamazsınız.",
"readWrite": "Okuma-yazma",
"readWriteDescription": "Oturum sahibi gibi yazabilir ve oturumla etkileşim kurabilirsiniz."
},
"expiryLabel": "Bağlantı süresinin dolması",
"createLinkButton": "Bağlantı oluştur",
"createShareButton": "Kullanıcıyla paylaş",
"searchUsersPlaceholder": "Kullanıcıları ara...",
"noUsersFound": "Hiç kullanıcı bulunamadı.",
"linkCreated": "Paylaşım bağlantısı oluşturuldu.",
"linkCopied": "Bağlantı panoya kopyalandı",
"copyLink": "Bağlantıyı kopyala",
"shareCreated": "Oturum paylaşıldı. Bu, onların Bağlantılar sekmesinde görünecektir.",
"shareFailed": "Paylaşım oluşturulamadı.",
"userLacksHostAccess": "Bu kullanıcının henüz bu sunucuya erişimi yok. Öncelikle sunucuyu onlarla paylaşın veya bunun yerine bir bağlantı kullanın.",
"activeShares": "Aktif hisseler",
"noActiveShares": "Bu oturum için aktif hisse senedi bulunmamaktadır.",
"revoke": "Geri çekmek",
"revokeConfirmTitle": "Bu hisseyi iptal etmek ister misiniz?",
"revokeConfirmDescription": "Bu paylaşımı kullanan herkes erişimini anında kaybedecektir.",
"revoked": "Hisseler geri alındı",
"revokeFailed": "Hisseleri geri alma işlemi başarısız oldu.",
"joinCount": "{{count}} katıl",
"joinCount_other": "{{count}} birleşiyor",
"expiresAt": "Süresi doluyor {{date}}",
"linkShareBadge": "Bağlantı",
"userShareBadge": "Kullanıcı: {{username}}",
"loadSharesFailed": "Aktif paylaşımlar yüklenemedi.",
"customHoursPlaceholder": "Erişim süresi (saat)"
},
"collab": {
"createRoom": "Yeni oda",
"roomName": "Oda adı",
"persistentRoom": "Kalıcı oda",
"persistentRoomHint": "Kalıcı olarak tahsis edilen odalar, toplantı bittikten sonra da listede kalır ve tekrar kullanılabilir.",
"noRooms": "Henüz oda yok. Toplantı başlatmak için bir oda oluşturun.",
"openRoom": "Açık",
"endRoom": "Toplantıyı sonlandırın",
"leaveRoom": "Ayrılmak",
"deleteMember": "Kaldırmak",
"removeMember": "{{name}}'ı kaldır",
"removeMemberTitle": "Bu üyeyi kaldırmak mı istiyorsunuz?",
"removeMemberDescription": "{{name}} bu odaya erişimini derhal kaybedecektir. Aktif olan tüm SSH izleyicilerinin bağlantısı kesilecektir.",
"invite": "Davet etmek",
"inviteTitle": "Üyeleri davet edin",
"members": "Üyeler",
"membersPanel": "Oda üyeleri ve kontrol talepleri",
"membersWithCount": "Üyeler ({{count}})",
"online": "çevrimiçi",
"offline": "çevrimdışı",
"you": "(Sen)",
"hostBadge": "Ev sahibi",
"presenterBadge": "Sunar",
"present": "Sunmak",
"presentTitle": "Sahneye çıkın",
"presentHost": "Ev sahibi",
"searchHosts": "Ev sahiplerini ara",
"noHostsFound": "Kullanılabilir sunucu bulunamadı.",
"presentProtocol": "Protokol",
"stopPresenting": "Sunum yapmayı bırakın",
"takeOver": "Devralmak",
"takeOverConfirmTitle": "Mevcut sunumu değiştirmek mi istiyorsunuz?",
"takeOverDescription": "{{name}} şu anda sunum yapıyor. Devralmak, herkesi o sahneden ayıracaktır.",
"emptyStage": "Kimse sunum yapmıyor. Sahneye çıkın ve bir sunum paylaşın.",
"stageLoading": "Sahneye bağlanmak...",
"stageEnded": "Sunum sona erdi.",
"roomEnded": "Bu toplantı sona erdi.",
"presenterLabel": "{{name}} sunuyor",
"guestLink": "Konuk bağlantısı",
"guestLinkOn": "Misafir bağlantısı aktif. Bağlantıya sahip herkes sahneyi izleyebilir.",
"guestLinkOff": "Misafir bağlantısı kapalı.",
"copyLink": "Bağlantıyı kopyala",
"rotateLink": "Bağlantıyı döndür",
"disableLink": "Bağlantıyı devre dışı bırak",
"rotateLinkConfirmTitle": "Konuk bağlantısını döndürelim mi?",
"rotateLinkDescription": "Mevcut bağlantı derhal çalışmayı durduracaktır. Bağlı SSH misafirlerinin bağlantısı kesilecektir.",
"disableLinkConfirmTitle": "Misafir bağlantısını devre dışı bırak?",
"disableLinkDescription": "Anonim SSH izleyicilerinin bağlantısı derhal kesilecek ve mevcut bağlantı çalışmayı durduracaktır.",
"linkCopied": "Bağlantı kopyalandı",
"roles": "Roller",
"users": "Kullanıcılar",
"searchUsers": "Kullanıcıları ara",
"endConfirmTitle": "Bu toplantıyı sonlandıralım mı?",
"endDescription": "{{name}} herkes için sona erecek ve yeniden açılamayacak.",
"endPersistentDescription": "{{name}} 'daki aktif sunum durdurulacaktır. Oda yeniden kullanıma açıktır.",
"deleteRoom": "Odayı sil",
"deleteConfirmTitle": "Bu odayı silmek mi?",
"deleteDescription": "{{name}} herkes için kalıcı olarak silinecek ve geri alınamaz.",
"invitedTo": "\"{{name}} \" etkinliğine davet edildiniz.",
"guest": {
"title": "Toplantı",
"waiting": "Sunucuyu bekliyoruz...",
"linkInvalid": "Bu konuk bağlantısı geçersiz veya toplantı sona erdi."
},
"reopenFromPanel": "Bu toplantı sekmesinin süresi doldu. Toplantılar panelinden odayı yeniden açın.",
"requestControl": "Talep kontrolü",
"cancelControlRequest": "İptal talebi",
"controlQueue": "Kontrol talepleri",
"noControlRequests": "Bekleyen talep yok.",
"grantControlTo": "Kontrolü {{name}}'a verin.",
"dismissControlRequest": "{{name}}'ın kontrol isteğini reddet",
"releaseControl": "Kontrolü serbest bırak",
"grantControl": "Kontrolü ona verin.",
"revokeControl": "Kontrolü geri alın",
"controlRequestedBy": "{{name}} kontrolü istedi",
"grant": "Hibe etmek",
"controlBadge": "Kontrol altında",
"youArePresenting": "Başka bir sekmede sunum yapıyorsunuz. Sunumu durdurun ve sahneyi karşı tarafa devredin.",
"created": "Oda oluşturuldu",
"invited": "Üyeler davet edildi",
"roomTab": "Toplantı"
},
"nav": {
"collab": "Toplantılar"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Oturum Paylaşımına İzin Ver",
"description": "Canlı terminal, RDP, VNC ve Telnet oturumlarının tüm örnek genelinde paylaşılmasına izin verin. Devre dışı bırakıldığında, sunucu başına paylaşım ayarlarının tümünü geçersiz kılar."
}
},
"host": {
"allowSessionSharing": {
"label": "Oturum Paylaşımına İzin Ver",
"description": "Bu sunucudaki canlı oturumların bağlantı yoluyla veya diğer kullanıcılarla paylaşılmasına izin verin."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "Посилання для спільного доступу створено",
"linkCopied": "Посилання скопійовано в буфер обміну",
"copyLink": "Копіювати посилання",
"shareCreated": "Сеанс спільний. Він відображатиметься на вкладці «Зв’язки».",
"shareFailed": "Не вдалося створити спільний доступ",
"userLacksHostAccess": "Цей користувач ще не має доступу до цього хоста. Спочатку надайте йому доступ до хоста або скористайтеся посиланням.",
"activeShares": "Активні акції",
"noActiveShares": "Немає активних поширень для цього сеансу",
"revoke": "Скасувати",
"revokeConfirmTitle": "Скасувати цей спільний доступ?",
"revokeConfirmDescription": "Будь-хто, хто користується цим ресурсом спільного доступу, негайно втратить доступ.",
"revoked": "Акції анульовано",
"revokeFailed": "Не вдалося скасувати спільний доступ",
"joinCount": "{{count}} приєднатися",
"joinCount_other": "{{count}} приєднується",
"expiresAt": "Термін дії закінчується {{date}}",
"linkShareBadge": "Посилання",
"userShareBadge": "Користувач: {{username}}",
"loadSharesFailed": "Не вдалося завантажити активні спільні ресурси",
"customHoursPlaceholder": "Години до закінчення терміну дії доступу"
},
"collab": {
"createRoom": "Нова кімната",
"roomName": "Назва кімнати",
"persistentRoom": "Постійна кімната",
"persistentRoomHint": "Постійні кімнати залишаються у списку після завершення зустрічі та можуть бути використані повторно.",
"noRooms": "Поки що немає кімнат. Створіть одну, щоб розпочати зустріч.",
"openRoom": "ВІДЧИНЕНО",
"endRoom": "Закінчити зустріч",
"leaveRoom": "Залишити",
"deleteMember": "Видалити",
"removeMember": "Видалити {{name}}",
"removeMemberTitle": "Видалити цього учасника?",
"removeMemberDescription": "{{name}} негайно втратить доступ до цієї кімнати. Будь-який активний переглядач SSH буде відключено.",
"invite": "Запросити",
"inviteTitle": "Запросити учасників",
"members": "Члени",
"membersPanel": "Учасники кімнати та запити на керування",
"membersWithCount": "Учасники ({{count}})",
"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": "Термін дії цієї вкладки зустрічі закінчився. Відкрийте кімнату знову на панелі Зустрічі.",
"requestControl": "Запит на керування",
"cancelControlRequest": "Скасувати запит",
"controlQueue": "Запити на керування",
"noControlRequests": "Немає запитів, що очікують на розгляд.",
"grantControlTo": "Надайте керування {{name}}",
"dismissControlRequest": "Відхилити запит керування від {{name}}",
"releaseControl": "Контроль випуску",
"grantControl": "Надайте контроль",
"revokeControl": "Поверніть собі контроль",
"controlRequestedBy": "{{name}} запросив керування",
"grant": "Грант",
"controlBadge": "Контроль",
"youArePresenting": "Ви презентуєте в іншій вкладці. Зупиніть презентацію, щоб передати сцену.",
"created": "Кімнату створено",
"invited": "Запрошені учасники",
"roomTab": "Зустріч"
},
"nav": {
"collab": "Зустрічі"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Дозволити спільний доступ до сеансу",
"description": "Дозволити спільний доступ до сеансів терміналу, RDP, VNC та Telnet для всього екземпляра. Перевизначає кожен перемикач спільного доступу для кожного хоста, якщо його вимкнено."
}
},
"host": {
"allowSessionSharing": {
"label": "Дозволити спільний доступ до сеансу",
"description": "Дозволити ділитися прямими трансляціями на цьому хості через посилання або з іншими користувачами"
}
}
}
}
@@ -0,0 +1,152 @@
{
"sessionSharing": {
"guestView": {
"loading": "Đang kết nối đến phiên chia sẻ...",
"linkInvalid": "Liên kết chia sẻ này không hợp lệ, đã hết hạn hoặc đã bị thu hồi.",
"rateLimited": "Đã thử quá nhiều lần, vui lòng thử lại sau.",
"sessionEnded": "Phiên này đã kết thúc",
"readOnlyBadge": "Chỉ xem",
"ownerLabel": "Chủ nhà"
},
"modalTitle": "Chia sẻ phiên họp",
"shareButton": "Chia sẻ",
"notReadyToShare": "Buổi học chưa sẵn sàng để chia sẻ.",
"modeTab": {
"link": "Liên kết",
"user": "Người dùng"
},
"linkModeDescription": "Bất kỳ ai có liên kết này đều có thể tham gia, không cần tài khoản.",
"userModeDescription": "Chia sẻ với một người dùng cụ thể đã có quyền truy cập vào máy chủ này. Nếu họ chưa có quyền truy cập, hãy chia sẻ máy chủ với họ trước hoặc sử dụng liên kết thay thế. Sau khi chia sẻ, phiên làm việc sẽ xuất hiện trong tab Kết nối của họ.",
"permissionLevel": {
"label": "Cấp độ quyền hạn",
"readOnly": "Chỉ đọc",
"readOnlyDescription": "Có thể xem phiên trực tiếp nhưng không thể gõ chữ hoặc tương tác.",
"readWrite": "Đọc-viết",
"readWriteDescription": "Có thể nhập liệu và tương tác với phiên làm việc giống như chủ sở hữu."
},
"expiryLabel": "Liên kết hết hạn",
"createLinkButton": "Tạo liên kết",
"createShareButton": "Chia sẻ với người dùng",
"searchUsersPlaceholder": "Tìm kiếm người dùng...",
"noUsersFound": "Không tìm thấy người dùng nào",
"linkCreated": "Đã tạo liên kết chia sẻ",
"linkCopied": "Liên kết đã được sao chép vào clipboard",
"copyLink": "Sao chép liên kết",
"shareCreated": "Phiên họp đã được chia sẻ. Nó sẽ xuất hiện trong tab Kết nối của họ.",
"shareFailed": "Không thể tạo chia sẻ",
"userLacksHostAccess": "Người dùng đó chưa có quyền truy cập vào máy chủ này. Hãy chia sẻ thông tin máy chủ với họ trước, hoặc sử dụng liên kết thay thế.",
"activeShares": "Cổ phiếu tích cực",
"noActiveShares": "Hiện không có cổ phiếu nào đang được giao dịch trong phiên này.",
"revoke": "Thu hồi",
"revokeConfirmTitle": "Thu hồi cổ phần này?",
"revokeConfirmDescription": "Bất kỳ ai sử dụng thư mục chia sẻ này sẽ mất quyền truy cập ngay lập tức.",
"revoked": "Cổ phần đã bị thu hồi",
"revokeFailed": "Không thể thu hồi cổ phần",
"joinCount": "{{count}} tham gia",
"joinCount_other": "{{count}} tham gia",
"expiresAt": "Hết hạn {{date}}",
"linkShareBadge": "Liên kết",
"userShareBadge": "Người dùng: {{username}}",
"loadSharesFailed": "Không thể tải các chia sẻ đang hoạt động",
"customHoursPlaceholder": "Số giờ cho đến khi hết hạn truy cập"
},
"collab": {
"createRoom": "Phòng mới",
"roomName": "Tên phòng",
"persistentRoom": "Phòng bền vững",
"persistentRoomHint": "Các phòng họp được lưu lại sẽ vẫn hiển thị sau khi cuộc họp kết thúc và có thể được sử dụng lại.",
"noRooms": "Chưa có phòng nào. Hãy tạo một phòng để bắt đầu cuộc họp.",
"openRoom": "Mở",
"endRoom": "Kết thúc cuộc họp",
"leaveRoom": "Rời khỏi",
"deleteMember": "Di dời",
"removeMember": "Xóa {{name}}",
"removeMemberTitle": "Xóa thành viên này?",
"removeMemberDescription": "{{name}} sẽ mất quyền truy cập vào phòng này ngay lập tức. Bất kỳ trình xem SSH nào đang hoạt động sẽ bị ngắt kết nối.",
"invite": "Mời",
"inviteTitle": "Mời thành viên",
"members": "Thành viên",
"membersPanel": "Thành viên phòng và yêu cầu kiểm soát",
"membersWithCount": "Thành viên ({{count}})",
"online": "trực tuyến",
"offline": "ngoại tuyến",
"you": "(Bạn)",
"hostBadge": "Chủ nhà",
"presenterBadge": "Giới thiệu",
"present": "Hiện tại",
"presentTitle": "Hãy lên sân khấu",
"presentHost": "Chủ nhà",
"searchHosts": "Tìm kiếm máy chủ",
"noHostsFound": "Không tìm thấy máy chủ nào khả dụng.",
"presentProtocol": "Giao thức",
"stopPresenting": "Dừng thuyết trình",
"takeOver": "Tiếp quản",
"takeOverConfirmTitle": "Bạn muốn thay thế bài thuyết trình hiện tại?",
"takeOverDescription": "{{name}} đang trình bày. Việc tiếp quản sẽ ngắt kết nối mọi người khỏi sân khấu đó.",
"emptyStage": "Hiện chưa có ai thuyết trình. Hãy lên sân khấu để chia sẻ một phiên thảo luận.",
"stageLoading": "Kết nối với sân khấu...",
"stageEnded": "Buổi thuyết trình kết thúc.",
"roomEnded": "Cuộc họp này đã kết thúc.",
"presenterLabel": "{{name}} đang trình bày",
"guestLink": "Liên kết khách",
"guestLinkOn": "Link dành cho khách đã được bật. Bất cứ ai có link đều có thể xem chương trình.",
"guestLinkOff": "Liên kết khách đã bị tắt.",
"copyLink": "Sao chép liên kết",
"rotateLink": "Xoay liên kết",
"disableLink": "Vô hiệu hóa liên kết",
"rotateLinkConfirmTitle": "Xoay liên kết khách?",
"rotateLinkDescription": "Kết nối hiện tại sẽ ngừng hoạt động ngay lập tức. Các máy khách SSH đang kết nối sẽ bị ngắt kết nối.",
"disableLinkConfirmTitle": "Vô hiệu hóa liên kết khách?",
"disableLinkDescription": "Những người xem SSH ẩn danh sẽ bị ngắt kết nối ngay lập tức và liên kết hiện tại sẽ ngừng hoạt động.",
"linkCopied": "Liên kết đã được sao chép",
"roles": "Vai trò",
"users": "Người dùng",
"searchUsers": "Tìm kiếm người dùng",
"endConfirmTitle": "Kết thúc cuộc họp này?",
"endDescription": "{{name}} sẽ kết thúc đối với tất cả mọi người và không thể mở lại.",
"endPersistentDescription": "Buổi thuyết trình đang diễn ra tại {{name}} sẽ dừng lại. Phòng vẫn có thể được sử dụng lại.",
"deleteRoom": "Xóa phòng",
"deleteConfirmTitle": "Xóa phòng này?",
"deleteDescription": "{{name}} sẽ bị xóa vĩnh viễn đối với tất cả mọi người và không thể hoàn tác.",
"invitedTo": "Bạn được mời đến \"{{name}}\"",
"guest": {
"title": "Cuộc họp",
"waiting": "Đang chờ người dẫn chương trình...",
"linkInvalid": "Liên kết dành cho khách này không hợp lệ hoặc cuộc họp đã kết thúc."
},
"reopenFromPanel": "Tab cuộc họp này đã hết hạn. Vui lòng mở lại phòng từ mục Cuộc họp.",
"requestControl": "Yêu cầu kiểm soát",
"cancelControlRequest": "Yêu cầu hủy bỏ",
"controlQueue": "Yêu cầu kiểm soát",
"noControlRequests": "Không có yêu cầu nào đang chờ xử lý.",
"grantControlTo": "Trao quyền điều khiển cho {{name}}",
"dismissControlRequest": "Bỏ qua yêu cầu điều khiển của {{name}}",
"releaseControl": "Kiểm soát giải phóng",
"grantControl": "Trao quyền kiểm soát",
"revokeControl": "Giành lại quyền kiểm soát",
"controlRequestedBy": "{{name}} yêu cầu quyền điều khiển",
"grant": "Khoản trợ cấp",
"controlBadge": "Trong tầm kiểm soát",
"youArePresenting": "Bạn đang thuyết trình ở một tab khác. Hãy dừng thuyết trình để nhường sân khấu.",
"created": "Phòng được tạo",
"invited": "Các thành viên được mời",
"roomTab": "Cuộc họp"
},
"nav": {
"collab": "Các cuộc họp"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "Cho phép chia sẻ phiên",
"description": "Cho phép chia sẻ các phiên terminal trực tiếp, RDP, VNC và Telnet trên toàn bộ máy chủ. Khi bị vô hiệu hóa, tùy chọn này sẽ ghi đè lên mọi tùy chọn chia sẻ trên từng máy chủ riêng lẻ."
}
},
"host": {
"allowSessionSharing": {
"label": "Cho phép chia sẻ phiên",
"description": "Cho phép chia sẻ các phiên trực tiếp trên máy chủ này qua liên kết hoặc với những người dùng khác."
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "已创建共享链接",
"linkCopied": "链接已复制到剪贴板",
"copyLink": "复制链接",
"shareCreated": "会话已共享。它将出现在对方的“连接”标签页中。",
"shareFailed": "创建共享失败",
"userLacksHostAccess": "该用户目前没有访问此主机的权限。请先与其共享主机,或使用链接进行共享。",
"activeShares": "有效共享",
"noActiveShares": "此会话暂无活跃共享",
"revoke": "撤销",
"revokeConfirmTitle": "撤销此共享?",
"revokeConfirmDescription": "任何使用此共享的用户都将立即失去访问权限。",
"revoked": "共享已撤销",
"revokeFailed": "撤销共享失败",
"joinCount": "{{count}} 人加入",
"joinCount_other": "{{count}} 人加入",
"expiresAt": "过期时间 {{date}}",
"linkShareBadge": "链接",
"userShareBadge": "用户:{{username}}",
"loadSharesFailed": "无法加载共享",
"customHoursPlaceholder": "过期前的小时数"
},
"collab": {
"createRoom": "新房间",
"roomName": "房间名称",
"persistentRoom": "持久房间",
"persistentRoomHint": "会议结束后,持久会议室仍会保留在列表中,可以重复使用。",
"noRooms": "暂无会议室。创建会议室即可开始会议。",
"openRoom": "打开",
"endRoom": "会议结束",
"leaveRoom": "离开",
"deleteMember": "消除",
"removeMember": "移除 {{name}}",
"removeMemberTitle": "移除此成员?",
"removeMemberDescription": "{{name}} 将立即失去对该房间的访问权限。所有正在使用的 SSH 查看器都将被断开连接。",
"invite": "邀请",
"inviteTitle": "邀请成员",
"members": "成员",
"membersPanel": "房间成员和控制请求",
"membersWithCount": "成员({{count}})",
"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": "此会议标签页已过期。请从“会议”面板重新打开会议室。",
"requestControl": "请求控制",
"cancelControlRequest": "取消请求",
"controlQueue": "控制请求",
"noControlRequests": "没有待处理的请求。",
"grantControlTo": "将控制权交给 {{name}}",
"dismissControlRequest": "驳回 {{name}}的控制请求",
"releaseControl": "发布控制",
"grantControl": "赋予控制权",
"revokeControl": "夺回控制权",
"controlRequestedBy": "{{name}} 请求控制权",
"grant": "授予",
"controlBadge": "掌控之中",
"youArePresenting": "您正在另一个标签页中进行演示。请停止演示,将演示权交给对方。",
"created": "创建房间",
"invited": "受邀成员",
"roomTab": "会议"
},
"nav": {
"collab": "会议"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "允许会话共享",
"description": "允许在整个实例范围内共享实时终端、RDP、VNC 和 Telnet 会话。禁用此功能将覆盖所有主机级别的共享设置。"
}
},
"host": {
"allowSessionSharing": {
"label": "允许会话共享",
"description": "允许通过链接或其他方式共享此主机上的实时会话。"
}
}
}
}
@@ -0,0 +1,152 @@
{
"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": "已建立分享連結",
"linkCopied": "連結已複製到剪貼簿",
"copyLink": "複製連結",
"shareCreated": "會話已共用。它將出現在對方的「連線」標籤中。",
"shareFailed": "建立共享失敗",
"userLacksHostAccess": "該使用者目前沒有存取此主機的權限。請先與其共享主機,或使用連結進行共享。",
"activeShares": "活躍股",
"noActiveShares": "本會話無活躍份額",
"revoke": "撤銷",
"revokeConfirmTitle": "撤銷此份額?",
"revokeConfirmDescription": "任何使用此共用資料夾的使用者都將立即失去存取權限。",
"revoked": "股份撤銷",
"revokeFailed": "撤銷股份失敗",
"joinCount": "{{count}} 加入",
"joinCount_other": "{{count}} 加入",
"expiresAt": "過期時間 {{date}}",
"linkShareBadge": "關聯",
"userShareBadge": "使用者: {{username}}",
"loadSharesFailed": "無法加載活動份額",
"customHoursPlaceholder": "存取到期前的小時數"
},
"collab": {
"createRoom": "新房間",
"roomName": "房間名稱",
"persistentRoom": "持久房間",
"persistentRoomHint": "會議結束後,持久會議室仍會保留在清單中,可重複使用。",
"noRooms": "暫無會議室。創建會議室即可開始會議。",
"openRoom": "打開",
"endRoom": "會議結束",
"leaveRoom": "離開",
"deleteMember": "消除",
"removeMember": "移除 {{name}}",
"removeMemberTitle": "移除此成員?",
"removeMemberDescription": "{{name}} 將立即失去該房間的存取權限。所有正在使用的 SSH 檢視器都將被斷開。",
"invite": "邀請",
"inviteTitle": "邀請成員",
"members": "成員",
"membersPanel": "房間成員和控制請求",
"membersWithCount": "成員({{count}})",
"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": "此會議標籤頁已過期。請從「會議」面板重新開啟會議室。",
"requestControl": "請求控制",
"cancelControlRequest": "取消請求",
"controlQueue": "控制請求",
"noControlRequests": "沒有待處理的請求。",
"grantControlTo": "將控制權交給 {{name}}",
"dismissControlRequest": "駁回 {{name}}的控制請求",
"releaseControl": "發布控制",
"grantControl": "賦予控制權",
"revokeControl": "奪回控制權",
"controlRequestedBy": "{{name}} 請求控制權",
"grant": "授予",
"controlBadge": "掌控之中",
"youArePresenting": "您正在另一個標籤頁中進行示範。請停止演示,將演示權交給對方。",
"created": "創建房間",
"invited": "受邀成員",
"roomTab": "會議"
},
"nav": {
"collab": "會議"
},
"settings": {
"admin": {
"globallyEnabled": {
"label": "允許會話共享",
"description": "允許在整個執行個體範圍內共用即時終端、RDP、VNC 和 Telnet 會話。停用此功能將覆蓋所有主機層級的共用設定。"
}
},
"host": {
"allowSessionSharing": {
"label": "允許會話共享",
"description": "允許透過連結或其他方式分享此主機上的即時會話。"
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"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 to a group 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"],
"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": {
"fields": [
{
"key": "allowSessionSharing",
"type": "boolean",
"labelKey": "settings.host.allowSessionSharing.label",
"descriptionKey": "settings.host.allowSessionSharing.description",
"default": true
}
]
}
}
},
"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";
+21
View File
@@ -0,0 +1,21 @@
{
"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 tsconfig.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react-xtermjs": "^1.0.10",
"redis": "^6.2.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -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.
}
+183
View File
@@ -0,0 +1,183 @@
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;
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;
},
/** 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,580 @@
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 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);
},
};
}
@@ -1,5 +1,5 @@
import type { WebSocket } from "ws"; import type { WebSocket } from "ws";
import { collabRuntimeStore } from "./runtime-store.js"; import type { CollabRuntimeStore } from "./runtime-store.js";
const PRESENCE_HEARTBEAT_MS = 15_000; const PRESENCE_HEARTBEAT_MS = 15_000;
@@ -10,14 +10,16 @@ export interface CollabRoomClient {
} }
/** /**
* Local WebSocket fan-out plus optional Redis pub/sub for multi-instance room * Local WebSocket fan-out plus the runtime store's pub/sub for room events and
* events and presence. The terminal WS server feeds subscribe/unsubscribe. * presence across instances. The terminal socket feeds subscribe and
* unsubscribe through the sessions.sharing service.
*/ */
class CollabRoomHub { export class CollabRoomHub {
private rooms = new Map<string, Set<CollabRoomClient>>(); private rooms = new Map<string, Set<CollabRoomClient>>();
private readonly heartbeat: NodeJS.Timeout;
constructor() { constructor(private readonly store: CollabRuntimeStore) {
collabRuntimeStore.onEvent((roomId, message) => { store.onEvent((roomId, message) => {
if ( if (
"type" in message && "type" in message &&
typeof message.type === "string" && typeof message.type === "string" &&
@@ -27,9 +29,10 @@ class CollabRoomHub {
} }
this.broadcastLocal(roomId, message); this.broadcastLocal(roomId, message);
}); });
setInterval(() => { this.heartbeat = setInterval(() => {
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId); for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
}, PRESENCE_HEARTBEAT_MS).unref(); }, PRESENCE_HEARTBEAT_MS);
this.heartbeat.unref();
} }
subscribe(roomId: string, client: CollabRoomClient): void { subscribe(roomId: string, client: CollabRoomClient): void {
@@ -63,16 +66,18 @@ class CollabRoomHub {
broadcast(roomId: string, message: object): void { broadcast(roomId: string, message: object): void {
this.broadcastLocal(roomId, message); this.broadcastLocal(roomId, message);
void collabRuntimeStore.publish(roomId, message); void this.store.publish(roomId, message);
} }
async onlineUsers( async onlineUsers(
roomId: string, roomId: string,
): Promise<Array<{ userId: string; username: string }>> { ): Promise<Array<{ userId: string; username: string }>> {
return collabRuntimeStore.onlineUsers( return this.store.onlineUsers(roomId, this.localOnlineUsers(roomId));
roomId, }
this.localOnlineUsers(roomId),
); dispose(): void {
clearInterval(this.heartbeat);
this.rooms.clear();
} }
private broadcastLocal(roomId: string, message: object): void { private broadcastLocal(roomId: string, message: object): void {
@@ -103,7 +108,7 @@ class CollabRoomHub {
private async refreshPresence(roomId: string): Promise<void> { private async refreshPresence(roomId: string): Promise<void> {
const localUsers = this.localOnlineUsers(roomId); const localUsers = this.localOnlineUsers(roomId);
await collabRuntimeStore.updatePresence(roomId, localUsers); await this.store.updatePresence(roomId, localUsers);
this.broadcast(roomId, { this.broadcast(roomId, {
type: "collab_online", type: "collab_online",
roomId, roomId,
@@ -111,5 +116,3 @@ class CollabRoomHub {
}); });
} }
} }
export const collabRoomHub = new CollabRoomHub();
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
import crypto from "crypto"; import crypto from "node:crypto";
import { createClient } from "redis"; import { createClient } from "redis";
import { sshLogger } from "../../utils/logger.js"; import type { PluginLogger } from "@termix/plugin-sdk/backend";
export interface CollabControlRequest { export interface CollabControlRequest {
userId: string; userId: string;
@@ -26,7 +26,14 @@ const PRESENCE_TTL_MS = 45_000;
const STATE_TTL_SECONDS = 12 * 60 * 60; const STATE_TTL_SECONDS = 12 * 60 * 60;
const CONNECT_RETRY_MS = 15_000; 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 { export class CollabRuntimeStore {
constructor(private readonly log: PluginLogger) {}
private readonly instanceId = crypto.randomUUID(); private readonly instanceId = crypto.randomUUID();
private publisher: ReturnType<typeof createClient> | null = null; private publisher: ReturnType<typeof createClient> | null = null;
private subscriber: ReturnType<typeof createClient> | null = null; private subscriber: ReturnType<typeof createClient> | null = null;
@@ -192,6 +199,8 @@ export class CollabRuntimeStore {
} }
async close(): Promise<void> { async close(): Promise<void> {
this.eventListeners.clear();
this.nextConnectAttempt = Number.POSITIVE_INFINITY;
await Promise.allSettled([ await Promise.allSettled([
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(), this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(), this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
@@ -246,9 +255,7 @@ export class CollabRuntimeStore {
} }
} }
}); });
sshLogger.info("Collaboration Redis runtime connected", { this.log.info("Collaboration Redis runtime connected");
operation: "collab_redis_connected",
});
this.nextConnectAttempt = 0; this.nextConnectAttempt = 0;
return true; return true;
} catch (error) { } catch (error) {
@@ -273,14 +280,10 @@ export class CollabRuntimeStore {
} }
private logRedisFailure(operation: string, error: unknown): void { private logRedisFailure(operation: string, error: unknown): void {
sshLogger.warn( this.log.warn(
"Collaboration Redis runtime unavailable; using local state", `Collaboration Redis runtime unavailable (${operation}), using local state: ${
{ error instanceof Error ? error.message : String(error)
operation: `collab_redis_${operation}`, }`,
error: error instanceof Error ? error.message : String(error),
},
); );
} }
} }
export const collabRuntimeStore = new CollabRuntimeStore();
@@ -0,0 +1,158 @@
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 = "sessions.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) {
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];
@@ -1,15 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { AlertCircle, Eye, Presentation, Users } from "lucide-react"; import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay"; import { RemoteDisplay } from "./shared";
import { import {
GuestTerminalView, GuestTerminalView,
type SessionParticipantInfo, type SessionParticipantInfo,
} from "@/features/session-sharing/SharedSessionView"; } from "./SharedSessionView";
import { import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
resolveCollabGuestStage,
type CollabGuestStage,
} from "@/api/collab-api";
const POLL_MS = 5000; const POLL_MS = 5000;
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { import {
Check, Check,
Crown, Crown,
@@ -10,8 +10,8 @@ import {
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/badge"; import { Badge } from "@termix/plugin-sdk/ui";
import { Button } from "@/components/button"; import { Button } from "@termix/plugin-sdk/ui";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -21,8 +21,8 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/alert-dialog"; } from "@termix/plugin-sdk/ui";
import type { CollabControlRequest, CollabRoomDetail } from "@/api/collab-api"; import type { CollabControlRequest, CollabRoomDetail } from "./api";
interface CollabMembersSidebarProps { interface CollabMembersSidebarProps {
detail: CollabRoomDetail; detail: CollabRoomDetail;
@@ -1,23 +1,19 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react"; import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
import { Button } from "@/components/button"; import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@/components/input"; import { Input } from "@termix/plugin-sdk/ui";
import { Badge } from "@/components/badge"; import { Badge } from "@termix/plugin-sdk/ui";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/dialog"; } from "@termix/plugin-sdk/ui";
import { import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api";
createCollabRoom, import { getErrorMessage } from "./shared";
listCollabRooms,
type CollabRoom,
} from "@/api/collab-api";
import { getErrorMessage } from "@/lib/error-message";
export function CollabPanel({ export function CollabPanel({
onOpenRoom, onOpenRoom,
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { PluginComponent } from "@/plugin-host/component-registry"; import {
import { useTranslation } from "react-i18next"; useHosts,
useTranslation,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
AlertCircle, AlertCircle,
@@ -12,14 +15,14 @@ import {
Square, Square,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/button"; import { Button } from "@termix/plugin-sdk/ui";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/dialog"; } from "@termix/plugin-sdk/ui";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -29,18 +32,19 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/alert-dialog"; } from "@termix/plugin-sdk/ui";
import { Input } from "@/components/input"; import {
Input,
PluginComponent,
resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import { CollabMembersSidebar } from "./CollabMembersSidebar"; import { CollabMembersSidebar } from "./CollabMembersSidebar";
import { import {
RemoteDisplay, RemoteDisplay,
createRemoteSessionToken, createRemoteSessionToken,
} from "@/features/remote-display/RemoteDisplay"; getErrorMessage,
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios"; wsUrlForPath,
import { getRoles } from "@/api/rbac-api"; } from "./shared";
import type { Role } from "@/main-axios";
import { pluginWsUrlForPath } from "@/lib/plugin-transport";
import { getErrorMessage } from "@/lib/error-message";
import { import {
deleteCollabRoom, deleteCollabRoom,
endCollabRoom, endCollabRoom,
@@ -54,10 +58,20 @@ import {
setCollabGuestLink, setCollabGuestLink,
setCollabStageControl, setCollabStageControl,
stopCollabStage, stopCollabStage,
getDirectory,
type CollabRoomDetail, type CollabRoomDetail,
type CollabStage, type CollabStage,
} from "@/api/collab-api"; type DirectoryRole,
import { resolveConnectionOrigin } from "@/lib/connection-origin"; } from "./api";
/** A host from the shell's list; the connection fields ride along by name. */
type SSHHostWithStatus = PluginHostRecord & {
enableSsh?: boolean;
enableRdp?: boolean;
enableVnc?: boolean;
enableTelnet?: boolean;
connectionOrigin?: unknown;
};
const PING_INTERVAL_MS = 30000; const PING_INTERVAL_MS = 30000;
const POLL_FALLBACK_MS = 15000; const POLL_FALLBACK_MS = 15000;
@@ -66,8 +80,7 @@ const POLL_FALLBACK_MS = 15000;
// the jwt cookie the way every terminal WS connection does. // the jwt cookie the way every terminal WS connection does.
async function roomEventsWsUrl(roomId: string): Promise<string | null> { async function roomEventsWsUrl(roomId: string): Promise<string | null> {
const { eventsWsPath } = await getCollabRoom(roomId); const { eventsWsPath } = await getCollabRoom(roomId);
const target = await pluginWsUrlForPath(eventsWsPath); return wsUrlForPath(eventsWsPath);
return target?.url ?? null;
} }
type PresentDraft = type PresentDraft =
@@ -97,7 +110,8 @@ export function CollabRoomTab({
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<PresentDraft | null>(null); const [draft, setDraft] = useState<PresentDraft | null>(null);
const [presentOpen, setPresentOpen] = useState(false); const [presentOpen, setPresentOpen] = useState(false);
const [presentLoading, setPresentLoading] = useState(false); const { loaded: hostsLoaded } = useHosts();
const presentLoading = !hostsLoaded;
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(true); const [membersOpen, setMembersOpen] = useState(true);
const [endOpen, setEndOpen] = useState(false); const [endOpen, setEndOpen] = useState(false);
@@ -112,14 +126,15 @@ export function CollabRoomTab({
>(null); >(null);
const [hostSearch, setHostSearch] = useState(""); const [hostSearch, setHostSearch] = useState("");
const [inviteSearch, setInviteSearch] = useState(""); const [inviteSearch, setInviteSearch] = useState("");
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]); const { hosts: shellHosts } = useHosts();
const hosts = shellHosts as SSHHostWithStatus[];
const [users, setUsers] = useState<Array<{ id: string; username: string }>>( const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
[], [],
); );
const [inviteSelection, setInviteSelection] = useState<Set<string>>( const [inviteSelection, setInviteSelection] = useState<Set<string>>(
new Set(), new Set(),
); );
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<DirectoryRole[]>([]);
const [roleSelection, setRoleSelection] = useState<Set<number>>(new Set()); const [roleSelection, setRoleSelection] = useState<Set<number>>(new Set());
const draftRef = useRef<PresentDraft | null>(null); const draftRef = useRef<PresentDraft | null>(null);
draftRef.current = draft; draftRef.current = draft;
@@ -306,18 +321,8 @@ export function CollabRoomTab({
} }
} }
async function openPresentDialog() { function openPresentDialog() {
setPresentOpen(true); setPresentOpen(true);
if (hosts.length === 0) {
setPresentLoading(true);
try {
setHosts(await getSSHHosts({ includeStatus: false }));
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setPresentLoading(false);
}
}
} }
function choosePresent( function choosePresent(
@@ -347,7 +352,8 @@ export function CollabRoomTab({
Number(host.id), Number(host.id),
await resolveConnectionOrigin({ await resolveConnectionOrigin({
connectionType: protocol, connectionType: protocol,
connectionOrigin: host.connectionOrigin, connectionOrigin: host.connectionOrigin as
"local" | "remote" | undefined,
}), }),
protocol, protocol,
); );
@@ -423,17 +429,9 @@ export function CollabRoomTab({
setInviteSelection(new Set()); setInviteSelection(new Set());
setRoleSelection(new Set()); setRoleSelection(new Set());
try { try {
const [userResult, roleResult] = await Promise.all([ const directory = await getDirectory();
getUserList(), setUsers(directory.users);
getRoles().catch(() => ({ roles: [] as Role[] })), setRoles(directory.roles);
]);
setUsers(
userResult.users.map((user) => ({
id: user.userId,
username: user.username,
})),
);
setRoles(roleResult.roles);
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error)); toast.error(getErrorMessage(error));
} }
@@ -1,23 +1,22 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react"; import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@/components/input"; import { Input } from "@termix/plugin-sdk/ui";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/dialog"; } from "@termix/plugin-sdk/ui";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/dropdown-menu"; } from "@termix/plugin-sdk/ui";
import { getUserList } from "@/main-axios";
import { import {
createSessionShare, createSessionShare,
getActiveSessionShares, getActiveSessionShares,
@@ -25,7 +24,8 @@ import {
type SessionShareProtocol, type SessionShareProtocol,
type SessionSharePermissionLevel, type SessionSharePermissionLevel,
type SessionShareRecord, type SessionShareRecord,
} from "@/api/session-sharing-api"; getDirectory,
} from "./api";
const EXPIRY_PRESETS = [ const EXPIRY_PRESETS = [
{ key: "oneHour", hours: 1 }, { key: "oneHour", hours: 1 },
@@ -83,12 +83,12 @@ export function ShareSessionModal({
if (!open || sharesLoaded) return; if (!open || sharesLoaded) return;
setSharesLoaded(true); setSharesLoaded(true);
Promise.all([ Promise.all([
getUserList().catch(() => ({ users: [] })), getDirectory().catch(() => ({ users: [] })),
getActiveSessionShares(hostId).catch(() => ({ shares: [] })), getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
]).then(([usersRes, sharesRes]) => { ]).then(([usersRes, sharesRes]) => {
setUsers( setUsers(
(usersRes.users ?? []).map((u) => ({ (usersRes.users ?? []).map((u) => ({
id: String(u.userId), id: String(u.id),
username: u.username, username: u.username,
})), })),
); );
@@ -313,7 +313,7 @@ export function ShareSessionModal({
<Input <Input
type="number" type="number"
autoFocus autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")} placeholder={t("sessionSharing.customHoursPlaceholder")}
value={customHours} value={customHours}
onChange={(e) => setCustomHours(e.target.value)} onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { AlertCircle, Eye, Users } from "lucide-react"; import { AlertCircle, Eye, Users } from "lucide-react";
@@ -7,10 +7,8 @@ import {
resolveShareLink, resolveShareLink,
type ResolvedShareLink, type ResolvedShareLink,
type ShareLinkErrorKind, type ShareLinkErrorKind,
} from "@/api/session-sharing-api"; } from "./api";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { Loader, RemoteDisplay, wsUrlForPath } from "./shared";
import { pluginWsUrlForPath } from "@/lib/plugin-transport";
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay";
const PING_INTERVAL_MS = 30000; const PING_INTERVAL_MS = 30000;
@@ -20,15 +18,13 @@ interface TerminalWsMessage {
[key: string]: unknown; [key: string]: unknown;
} }
// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod). // A shared session link always resolves against the desktop app's embedded
// Duplicated rather than extracted from that file to avoid touching it here. // local backend: joining a session on someone else's remote server is not
// A shared session link is always resolved against the desktop app's // supported from the desktop app.
// embedded local backend -- joining a session hosted on someone else's
// remote server isn't supported from the desktop app today.
async function resolveTerminalWsUrl(wsPath: string): Promise<string> { async function resolveTerminalWsUrl(wsPath: string): Promise<string> {
const target = await pluginWsUrlForPath(wsPath); const url = await wsUrlForPath(wsPath);
if (!target) throw new Error("No terminal endpoint is available"); if (!url) throw new Error("No terminal endpoint is available");
return target.url; return url;
} }
export interface SessionParticipantInfo { export interface SessionParticipantInfo {
@@ -332,12 +328,7 @@ export default function SharedSessionView() {
style={{ backgroundColor: "var(--bg-base)" }} style={{ backgroundColor: "var(--bg-base)" }}
> >
<div className="relative flex-1 min-h-0"> <div className="relative flex-1 min-h-0">
{loading && ( {loading && <Loader message={t("sessionSharing.guestView.loading")} />}
<SimpleLoader
visible={true}
message={t("sessionSharing.guestView.loading")}
/>
)}
{!loading && error && ( {!loading && error && (
<CenteredMessage <CenteredMessage
icon={ icon={
+410
View File
@@ -0,0 +1,410 @@
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;
export function bindApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("Session sharing is not running");
return client;
}
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,
): Promise<CreateSessionShareResponse> {
return call(
() => api().post<CreateSessionShareResponse>("/create", request),
"Failed to create session share",
);
}
export function getActiveSessionShares(
hostId: number,
): Promise<{ shares: SessionShareRecord[] }> {
return call(
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`),
"Failed to fetch active session shares",
);
}
export function revokeSessionShare(shareId: string): Promise<unknown> {
return call(
() => api().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(): Promise<{
users: DirectoryUser[];
roles: DirectoryRole[];
}> {
return call(
() =>
api().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;
controllerUserId: string | null;
controlRequests: CollabControlRequest[];
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
eventsWsPath: string;
}
const room = (roomId: string, suffix = "") =>
`/rooms/${encodeURIComponent(roomId)}${suffix}`;
export function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
return call(
() => api().get<{ rooms: CollabRoom[] }>("/rooms"),
"Failed to list rooms",
);
}
export function createCollabRoom(
name: string,
persistent: boolean,
): Promise<{ room: CollabRoom }> {
return call(
() => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }),
"Failed to create room",
);
}
export function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
return call(
() => api().get<CollabRoomDetail>(room(roomId)),
"Failed to get room",
);
}
export async function inviteCollabMembers(
roomId: string,
targets: { userIds?: string[]; roleIds?: number[] },
): Promise<void> {
await call(
() => api().post(room(roomId, "/members"), targets),
"Failed to invite members",
);
}
export async function removeCollabMember(
roomId: string,
userId: string,
): Promise<void> {
await call(
() => api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)),
"Failed to remove member",
);
}
export 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",
);
}
export async function stopCollabStage(roomId: string): Promise<void> {
await call(
() => api().post(room(roomId, "/stop")),
"Failed to stop presenting",
);
}
export function getCollabStage(
roomId: string,
): Promise<{ stage: CollabStage | null }> {
return call(
() => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")),
"Failed to resolve stage",
);
}
export async function setCollabStageControl(
roomId: string,
userId: string | null,
): Promise<void> {
await call(
() => api().post(room(roomId, "/control"), { userId }),
"Failed to change stage control",
);
}
export function requestCollabStageControl(
roomId: string,
): Promise<{ request: CollabControlRequest }> {
return call(
() =>
api().post<{ request: CollabControlRequest }>(
room(roomId, "/control/request"),
),
"Failed to request control",
);
}
export function listCollabControlRequests(
roomId: string,
): Promise<{ requests: CollabControlRequest[] }> {
return call(
() =>
api().get<{ requests: CollabControlRequest[] }>(
room(roomId, "/control/requests"),
),
"Failed to list control requests",
);
}
export async function dismissCollabControlRequest(
roomId: string,
userId: string,
): Promise<void> {
await call(
() =>
api().delete(
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
),
"Failed to dismiss control request",
);
}
export async function endCollabRoom(roomId: string): Promise<void> {
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
}
export async function deleteCollabRoom(roomId: string): Promise<void> {
await call(() => api().delete(room(roomId)), "Failed to delete room");
}
export 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",
);
}
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,233 @@
import { useEffect, 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, getSharedWithMe, listCollabRooms } from "./api";
import CollabGuestView from "./CollabGuestView";
import { CollabPanel } from "./CollabPanel";
import { CollabRoomTab } from "./CollabRoomTab";
import SharedSessionView from "./SharedSessionView";
import {
ShareDialogHost,
closeShareDialog,
openShareDialog,
type ShareTarget,
} from "./share-dialog";
import { REMOTE_DISPLAY_SLOT } from "./shared";
const COLLAB = "collab";
const SHARE_ACTION = "session-sharing.share";
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
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;
return (
<CollabRoomTab
roomId={typeof roomId === "string" ? roomId : undefined}
isVisible={isVisible}
/>
);
}
function CollabPanelView({ shell }: PanelProps) {
return (
<CollabPanel
onOpenRoom={(room) =>
shell.openTab(null, COLLAB, {
label: room.name,
forceNewTab: true,
data: { roomId: room.id },
})
}
/>
);
}
/**
* 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 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;
}
function isRemoteDesktopContext(
value: unknown,
): value is RemoteDesktopToolbarContext {
return (
!!value &&
typeof value === "object" &&
"protocol" in value &&
"hostId" in value
);
}
export function activate(app: TermixApp): void {
bindApi(app.api);
app.onDispose(() => {
closeShareDialog();
bindApi(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,
}
: 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",
});
}
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>
>,
});
// Core's active connections list asks for sessions shared with the user.
app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), {
permission: "use",
});
}
export function deactivate(): void {
closeShareDialog();
}
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from "react";
import { ShareSessionModal } from "./ShareSessionModal";
import type { SessionShareProtocol } from "./api";
/** What the share dialog needs to share one live session. */
export interface ShareTarget {
hostId: number;
sessionId: string;
protocol: SessionShareProtocol;
tabInstanceId?: string;
}
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}
/>
);
}
@@ -0,0 +1,90 @@
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;
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 wsUrlForPath(wsPath: string): Promise<string | null> {
const [pathname, query] = wsPath.split("?", 2);
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
if (!match) return null;
const target = await pluginWsUrl(match[1], match[2]);
if (!target) return null;
if (!query) return target.url;
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
}
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,182 @@
import http from "node:http";
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]);
},
},
};
}
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);
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>(
"sessions.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,275 @@
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("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("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,423 @@
/**
* 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 && 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,196 @@
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("shell.overlay")).toHaveLength(2);
expect(rendered.registered.actions().sort()).toEqual([
"session-sharing.share",
"sessions.sharedWithMe",
]);
});
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("answers sessions shared with the user through the api", async () => {
const shared = [{ sessionId: "s", isOwnSession: false }];
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi({
get: vi.fn(async () => ({ data: shared })) as never,
}),
});
await expect(invokeAction("sessions.sharedWithMe")).resolves.toEqual(
shared,
);
});
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([]);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more