mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Lets users sign in with a username and password checked against an LDAP or Active Directory server. Members of an admin group can be made admins.
|
||||
@@ -0,0 +1,27 @@
|
||||
# LDAP
|
||||
|
||||
Sign in with a username and password checked against an LDAP or Active Directory server.
|
||||
|
||||
## Features
|
||||
|
||||
- Sign in with a username and password checked against an LDAP or Active Directory server.
|
||||
- Add more than one directory. Each gets its own option on the login screen.
|
||||
- Make members of an admin group Termix admins.
|
||||
|
||||
## Settings
|
||||
|
||||
### Admin
|
||||
|
||||
- Directories: the LDAP servers, search bases and admin group for each directory.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `ldap.manage`: Add, edit and remove LDAP directories. Only admins have it by default.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Teken aan met LDAP",
|
||||
"loginWithProvider": "Teken aan met {{name}}",
|
||||
"loading": "Laai tans...",
|
||||
"requiredField": "Hierdie veld is verpligtend",
|
||||
"username": "LDAP-gebruikersnaam",
|
||||
"password": "LDAP-wagwoord",
|
||||
"signIn": "Aanmeld",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Bestuur LDAP-gidse",
|
||||
"description": "Voeg die LDAP-gidse by, verander en verwyder dit waarmee mense kan aanmeld."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Bekyk dokumente",
|
||||
"none": "Geen LDAP-gidse gekonfigureer nie.",
|
||||
"add": "Voeg gids by",
|
||||
"edit": "Wysig gids",
|
||||
"delete": "Vee gids uit",
|
||||
"deleteConfirm": "Vee hierdie gids uit? Gebruikers wat daarmee aanmeld, sal nie kan aanmeld nie.",
|
||||
"name": "Vertoonnaam",
|
||||
"namePlaceholder": "Maatskappygids",
|
||||
"nameRequired": "'n Vertoonnaam word vereis",
|
||||
"enabled": "Geaktiveer",
|
||||
"save": "Stoor gids",
|
||||
"saving": "Stoor...",
|
||||
"saved": "Gestoor",
|
||||
"saveFailed": "Kon nie stoor nie",
|
||||
"deleted": "Verwyder",
|
||||
"deleteFailed": "Kon nie verwyder nie",
|
||||
"loadFailed": "Kon nie LDAP-gidse laai nie",
|
||||
"cancel": "Kanselleer"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-gasheer",
|
||||
"port": "Hawe",
|
||||
"useTls": "Gebruik TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Wagwoord",
|
||||
"secretKeep": "'n Wagwoord is gestoor. Los dit leeg om dit te behou.",
|
||||
"userSearchBase": "Gebruikersoekbasis",
|
||||
"userSearchFilter": "Gebruikersoekfilter",
|
||||
"usernameAttr": "Gebruikersnaam-kenmerk",
|
||||
"displayNameAttr": "Vertoonnaam-kenmerk",
|
||||
"groupSearchBase": "Groepsoekbasis",
|
||||
"adminGroup": "Admingroep",
|
||||
"allowedUsers": "Toegelate Gebruikers"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "تسجيل الدخول باستخدام LDAP",
|
||||
"loginWithProvider": "تسجيل الدخول باستخدام {{name}}",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"requiredField": "هذا الحقل مطلوب",
|
||||
"username": "اسم مستخدم LDAP",
|
||||
"password": "كلمة مرور LDAP",
|
||||
"signIn": "تسجيل الدخول",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "إدارة أدلة LDAP",
|
||||
"description": "أضف، غيّر، واحذف أدلة LDAP التي يمكن للأشخاص تسجيل الدخول بها."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "عرض المستندات",
|
||||
"none": "لم يتم تكوين أي أدلة LDAP.",
|
||||
"add": "إضافة دليل",
|
||||
"edit": "تحرير الدليل",
|
||||
"delete": "حذف الدليل",
|
||||
"deleteConfirm": "هل تريد حذف هذا المجلد؟ لن يتمكن المستخدمون الذين يسجلون الدخول من خلاله من تسجيل الدخول.",
|
||||
"name": "اسم العرض",
|
||||
"namePlaceholder": "دليل الشركات",
|
||||
"nameRequired": "اسم العرض مطلوب",
|
||||
"enabled": "مُمكّن",
|
||||
"save": "حفظ الدليل",
|
||||
"saving": "جارٍ الحفظ...",
|
||||
"saved": "محفوظ",
|
||||
"saveFailed": "فشل في الحفظ",
|
||||
"deleted": "محذوف",
|
||||
"deleteFailed": "فشل في الحذف",
|
||||
"loadFailed": "فشل تحميل أدلة LDAP",
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
"fields": {
|
||||
"host": "مضيف LDAP",
|
||||
"port": "منفذ",
|
||||
"useTls": "استخدام TLS (LDAPS)",
|
||||
"bindDn": "اسم مميز للربط",
|
||||
"bindPassword": "كلمة مرور الارتباط",
|
||||
"secretKeep": "تم حفظ كلمة المرور. اترك هذا الحقل فارغًا للاحتفاظ بها.",
|
||||
"userSearchBase": "قاعدة بحث المستخدمين",
|
||||
"userSearchFilter": "مرشح بحث المستخدمين",
|
||||
"usernameAttr": "سمة اسم المستخدم",
|
||||
"displayNameAttr": "سمة اسم العرض",
|
||||
"groupSearchBase": "قاعدة بحث المجموعات",
|
||||
"adminGroup": "مجموعة المسؤولين",
|
||||
"allowedUsers": "المستخدمون المسموح لهم"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Влизане с LDAP",
|
||||
"loginWithProvider": "Влезте с {{name}}",
|
||||
"loading": "Зареждане...",
|
||||
"requiredField": "Това поле е задължително",
|
||||
"username": "Потребителско име на LDAP",
|
||||
"password": "LDAP парола",
|
||||
"signIn": "Вход",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Управление на LDAP директории",
|
||||
"description": "Добавяне, промяна и премахване на LDAP директории, с които хората могат да влизат."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Преглед на документи",
|
||||
"none": "Няма конфигурирани LDAP директории.",
|
||||
"add": "Добавяне на директория",
|
||||
"edit": "Редактиране на директория",
|
||||
"delete": "Изтриване на директория",
|
||||
"deleteConfirm": "Да се изтрие ли тази директория? Потребителите, които влязат с нея, няма да могат да влязат.",
|
||||
"name": "Показвано име",
|
||||
"namePlaceholder": "Фирмен указател",
|
||||
"nameRequired": "Изисква се показвано име",
|
||||
"enabled": "Активирано",
|
||||
"save": "Запазване на директорията",
|
||||
"saving": "Запазване...",
|
||||
"saved": "Запазено",
|
||||
"saveFailed": "Запазването не бе успешно",
|
||||
"deleted": "Изтрито",
|
||||
"deleteFailed": "Изтриването не бе успешно",
|
||||
"loadFailed": "Неуспешно зареждане на LDAP директории",
|
||||
"cancel": "Отказ"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP хост",
|
||||
"port": "Порт",
|
||||
"useTls": "Използвайте TLS (LDAPS)",
|
||||
"bindDn": "Обвързване DN",
|
||||
"bindPassword": "Свързване на парола",
|
||||
"secretKeep": "Паролата е запазена. Оставете това празно, за да я запазите.",
|
||||
"userSearchBase": "База за търсене на потребители",
|
||||
"userSearchFilter": "Филтър за търсене на потребители",
|
||||
"usernameAttr": "Атрибут на потребителското име",
|
||||
"displayNameAttr": "Атрибут за показвано име",
|
||||
"groupSearchBase": "База за групово търсене",
|
||||
"adminGroup": "Административна група",
|
||||
"allowedUsers": "Разрешени потребители"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Inicia la sessió amb LDAP",
|
||||
"loginWithProvider": "Inicia la sessió amb {{name}}",
|
||||
"loading": "S'està carregant...",
|
||||
"requiredField": "Aquest camp és obligatori",
|
||||
"username": "Nom d'usuari LDAP",
|
||||
"password": "Contrasenya LDAP",
|
||||
"signIn": "Inicia la sessió",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestiona els directoris LDAP",
|
||||
"description": "Afegiu, canvieu i elimineu els directoris LDAP amb què les persones poden iniciar sessió."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Veure documents",
|
||||
"none": "No hi ha cap directori LDAP configurat.",
|
||||
"add": "Afegeix un directori",
|
||||
"edit": "Edita el directori",
|
||||
"delete": "Suprimeix el directori",
|
||||
"deleteConfirm": "Voleu suprimir aquest directori? Els usuaris que hi iniciïn la sessió no podran iniciar la sessió.",
|
||||
"name": "Nom de visualització",
|
||||
"namePlaceholder": "Directori d'empreses",
|
||||
"nameRequired": "Cal un nom de visualització",
|
||||
"enabled": "Activat",
|
||||
"save": "Desa el directori",
|
||||
"saving": "Desant...",
|
||||
"saved": "Desat",
|
||||
"saveFailed": "No s'ha pogut desar",
|
||||
"deleted": "Suprimit",
|
||||
"deleteFailed": "No s'ha pogut suprimir",
|
||||
"loadFailed": "No s'han pogut carregar els directoris LDAP",
|
||||
"cancel": "Cancel·la"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Amfitrió LDAP",
|
||||
"port": "Port",
|
||||
"useTls": "Utilitza TLS (LDAPS)",
|
||||
"bindDn": "Vinculació de nom de domini",
|
||||
"bindPassword": "Contrasenya de vinculació",
|
||||
"secretKeep": "S'ha desat una contrasenya. Deixeu-ho en blanc per conservar-la.",
|
||||
"userSearchBase": "Base de cerca d'usuaris",
|
||||
"userSearchFilter": "Filtre de cerca d'usuaris",
|
||||
"usernameAttr": "Atribut de nom d'usuari",
|
||||
"displayNameAttr": "Atribut del nom de visualització",
|
||||
"groupSearchBase": "Base de cerca de grups",
|
||||
"adminGroup": "Grup d'administradors",
|
||||
"allowedUsers": "Usuaris permesos"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Přihlášení pomocí LDAP",
|
||||
"loginWithProvider": "Přihlaste se pomocí {{name}}",
|
||||
"loading": "Načítání...",
|
||||
"requiredField": "Toto pole je povinné",
|
||||
"username": "Uživatelské jméno LDAP",
|
||||
"password": "Heslo LDAP",
|
||||
"signIn": "Přihlásit se",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Správa adresářů LDAP",
|
||||
"description": "Přidávat, měnit a odebírat adresáře LDAP, pomocí kterých se lidé mohou přihlašovat."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Zobrazit dokumenty",
|
||||
"none": "Nejsou nakonfigurovány žádné adresáře LDAP.",
|
||||
"add": "Přidat adresář",
|
||||
"edit": "Upravit adresář",
|
||||
"delete": "Smazat adresář",
|
||||
"deleteConfirm": "Smazat tento adresář? Uživatelé, kteří se pomocí něj přihlásí, se nebudou moci přihlásit.",
|
||||
"name": "Zobrazovaný název",
|
||||
"namePlaceholder": "Adresář firem",
|
||||
"nameRequired": "Je vyžadováno zobrazované jméno",
|
||||
"enabled": "Povoleno",
|
||||
"save": "Uložit adresář",
|
||||
"saving": "Ukládání…",
|
||||
"saved": "Uloženo",
|
||||
"saveFailed": "Uložení se nezdařilo",
|
||||
"deleted": "Smazáno",
|
||||
"deleteFailed": "Smazání se nezdařilo",
|
||||
"loadFailed": "Nepodařilo se načíst adresáře LDAP",
|
||||
"cancel": "Zrušit"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Hostitel LDAP",
|
||||
"port": "Port",
|
||||
"useTls": "Používejte TLS (LDAPS)",
|
||||
"bindDn": "Vázat DN",
|
||||
"bindPassword": "Vázat heslo",
|
||||
"secretKeep": "Heslo je uloženo. Pro jeho zachování nechte toto pole prázdné.",
|
||||
"userSearchBase": "Vyhledávací báze uživatelů",
|
||||
"userSearchFilter": "Filtr vyhledávání uživatelů",
|
||||
"usernameAttr": "Atribut uživatelského jména",
|
||||
"displayNameAttr": "Atribut zobrazovaného názvu",
|
||||
"groupSearchBase": "Základna pro vyhledávání skupin",
|
||||
"adminGroup": "Skupina administrátorů",
|
||||
"allowedUsers": "Povolení uživatelé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Log ind med LDAP",
|
||||
"loginWithProvider": "Log ind med {{name}}",
|
||||
"loading": "Indlæser...",
|
||||
"requiredField": "Dette felt er påkrævet",
|
||||
"username": "LDAP-brugernavn",
|
||||
"password": "LDAP-adgangskode",
|
||||
"signIn": "Log ind",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Administrer LDAP-mapper",
|
||||
"description": "Tilføj, rediger og fjern de LDAP-mapper, som folk kan logge ind med."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Se dokumenter",
|
||||
"none": "Ingen LDAP-mapper konfigureret.",
|
||||
"add": "Tilføj mappe",
|
||||
"edit": "Rediger mappe",
|
||||
"delete": "Slet mappe",
|
||||
"deleteConfirm": "Vil du slette denne mappe? Brugere, der logger ind med den, vil ikke kunne logge ind.",
|
||||
"name": "Vist navn",
|
||||
"namePlaceholder": "Firmaregister",
|
||||
"nameRequired": "Et visningsnavn er påkrævet",
|
||||
"enabled": "Aktiveret",
|
||||
"save": "Gem mappe",
|
||||
"saving": "Gemmer...",
|
||||
"saved": "Gemt",
|
||||
"saveFailed": "Kunne ikke gemme",
|
||||
"deleted": "Slettet",
|
||||
"deleteFailed": "Kunne ikke slette",
|
||||
"loadFailed": "Kunne ikke indlæse LDAP-mapper",
|
||||
"cancel": "Ophæve"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-vært",
|
||||
"port": "Havn",
|
||||
"useTls": "Brug TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind adgangskode",
|
||||
"secretKeep": "En adgangskode er gemt. Lad dette felt være tomt for at beholde den.",
|
||||
"userSearchBase": "Brugersøgningsbase",
|
||||
"userSearchFilter": "Brugersøgningsfilter",
|
||||
"usernameAttr": "Brugernavnsattribut",
|
||||
"displayNameAttr": "Visningsnavnattribut",
|
||||
"groupSearchBase": "Gruppesøgningsbase",
|
||||
"adminGroup": "Admingruppe",
|
||||
"allowedUsers": "Tilladte brugere"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Anmeldung mit LDAP",
|
||||
"loginWithProvider": "Anmeldung mit {{name}}",
|
||||
"loading": "Laden...",
|
||||
"requiredField": "Dieses Feld ist erforderlich",
|
||||
"username": "LDAP-Benutzername",
|
||||
"password": "LDAP-Passwort",
|
||||
"signIn": "Anmelden",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "LDAP-Verzeichnisse verwalten",
|
||||
"description": "LDAP-Verzeichnisse hinzufügen, ändern und entfernen, mit denen sich Benutzer anmelden können."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Dokumentation ansehen",
|
||||
"none": "Es sind keine LDAP-Verzeichnisse konfiguriert.",
|
||||
"add": "Verzeichnis hinzufügen",
|
||||
"edit": "Verzeichnis bearbeiten",
|
||||
"delete": "Verzeichnis löschen",
|
||||
"deleteConfirm": "Soll dieses Verzeichnis gelöscht werden? Benutzer, die sich damit anmelden, können sich dann nicht mehr anmelden.",
|
||||
"name": "Anzeigename",
|
||||
"namePlaceholder": "Firmenverzeichnis",
|
||||
"nameRequired": "Ein Anzeigename ist erforderlich.",
|
||||
"enabled": "Aktiviert",
|
||||
"save": "Speicherverzeichnis",
|
||||
"saving": "Wird gespeichert …",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleted": "Gelöscht",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"loadFailed": "LDAP-Verzeichnisse konnten nicht geladen werden",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-Host",
|
||||
"port": "Port",
|
||||
"useTls": "TLS verwenden (LDAPS)",
|
||||
"bindDn": "Bind-DN",
|
||||
"bindPassword": "Bind-Passwort",
|
||||
"secretKeep": "Ein Passwort wurde gespeichert. Lassen Sie dieses Feld leer, um es zu behalten.",
|
||||
"userSearchBase": "Benutzer-Suchbasis",
|
||||
"userSearchFilter": "Benutzersuchfilter",
|
||||
"usernameAttr": "Benutzernamen-Attribut",
|
||||
"displayNameAttr": "Anzeigename-Attribut",
|
||||
"groupSearchBase": "Gruppen-Suchbasis",
|
||||
"adminGroup": "Admin-Gruppe",
|
||||
"allowedUsers": "Zugelassene Benutzer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Σύνδεση με LDAP",
|
||||
"loginWithProvider": "Σύνδεση με {{name}}",
|
||||
"loading": "Φόρτωση...",
|
||||
"requiredField": "Αυτό το πεδίο είναι υποχρεωτικό",
|
||||
"username": "Όνομα χρήστη LDAP",
|
||||
"password": "Κωδικός πρόσβασης LDAP",
|
||||
"signIn": "Σύνδεση",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Διαχείριση καταλόγων LDAP",
|
||||
"description": "Προσθήκη, αλλαγή και κατάργηση των καταλόγων LDAP με τους οποίους μπορούν να συνδεθούν οι χρήστες."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Προβολή εγγράφων",
|
||||
"none": "Δεν έχουν ρυθμιστεί κατάλογοι LDAP.",
|
||||
"add": "Προσθήκη καταλόγου",
|
||||
"edit": "Επεξεργασία καταλόγου",
|
||||
"delete": "Διαγραφή καταλόγου",
|
||||
"deleteConfirm": "Διαγραφή αυτού του καταλόγου; Οι χρήστες που συνδέονται με αυτόν δεν θα μπορούν να συνδεθούν.",
|
||||
"name": "Εμφανιζόμενο όνομα",
|
||||
"namePlaceholder": "Κατάλογος εταιρείας",
|
||||
"nameRequired": "Απαιτείται εμφανιζόμενο όνομα",
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"save": "Αποθήκευση καταλόγου",
|
||||
"saving": "Οικονομία...",
|
||||
"saved": "Αποθηκεύτηκε",
|
||||
"saveFailed": "Η αποθήκευση απέτυχε",
|
||||
"deleted": "Διαγράφηκε",
|
||||
"deleteFailed": "Η διαγραφή απέτυχε",
|
||||
"loadFailed": "Αποτυχία φόρτωσης καταλόγων LDAP",
|
||||
"cancel": "Ματαίωση"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Κεντρικός υπολογιστής LDAP",
|
||||
"port": "Λιμάνι",
|
||||
"useTls": "Χρήση TLS (LDAPS)",
|
||||
"bindDn": "Σύνδεση DN",
|
||||
"bindPassword": "Σύνδεση κωδικού πρόσβασης",
|
||||
"secretKeep": "Ένας κωδικός πρόσβασης αποθηκεύτηκε. Αφήστε το κενό για να τον διατηρήσετε.",
|
||||
"userSearchBase": "Βάση αναζήτησης χρηστών",
|
||||
"userSearchFilter": "Φίλτρο αναζήτησης χρήστη",
|
||||
"usernameAttr": "Χαρακτηριστικό ονόματος χρήστη",
|
||||
"displayNameAttr": "Χαρακτηριστικό ονόματος εμφάνισης",
|
||||
"groupSearchBase": "Βάση αναζήτησης ομάδας",
|
||||
"adminGroup": "Ομάδα Διαχειριστών",
|
||||
"allowedUsers": "Επιτρεπόμενοι χρήστες"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Iniciar sesión con LDAP",
|
||||
"loginWithProvider": "Iniciar sesión con {{name}}",
|
||||
"loading": "Cargando...",
|
||||
"requiredField": "Este campo es obligatorio",
|
||||
"username": "Nombre de usuario LDAP",
|
||||
"password": "Contraseña LDAP",
|
||||
"signIn": "Iniciar sesión",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Administrar directorios LDAP",
|
||||
"description": "Agregar, modificar y eliminar los directorios LDAP con los que los usuarios pueden iniciar sesión."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Ver documentación",
|
||||
"none": "No hay directorios LDAP configurados.",
|
||||
"add": "Agregar directorio",
|
||||
"edit": "Editar directorio",
|
||||
"delete": "Eliminar directorio",
|
||||
"deleteConfirm": "¿Eliminar este directorio? Los usuarios que inicien sesión con él no podrán acceder.",
|
||||
"name": "Nombre para mostrar",
|
||||
"namePlaceholder": "Directorio de empresas",
|
||||
"nameRequired": "Se requiere un nombre para mostrar.",
|
||||
"enabled": "Activado",
|
||||
"save": "Guardar directorio",
|
||||
"saving": "Guardando...",
|
||||
"saved": "Guardado",
|
||||
"saveFailed": "Error al guardar",
|
||||
"deleted": "Eliminado",
|
||||
"deleteFailed": "Error al eliminar",
|
||||
"loadFailed": "No se pudieron cargar los directorios LDAP.",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Host LDAP",
|
||||
"port": "Puerto",
|
||||
"useTls": "Usar TLS (LDAPS)",
|
||||
"bindDn": "DN de enlace",
|
||||
"bindPassword": "Contraseña de enlace",
|
||||
"secretKeep": "Se ha guardado una contraseña. Déjelo en blanco para conservarla.",
|
||||
"userSearchBase": "Base de búsqueda de usuarios",
|
||||
"userSearchFilter": "Filtro de búsqueda de usuarios",
|
||||
"usernameAttr": "Atributo de nombre de usuario",
|
||||
"displayNameAttr": "Atributo de nombre visible",
|
||||
"groupSearchBase": "Base de búsqueda de grupos",
|
||||
"adminGroup": "Grupo de administradores",
|
||||
"allowedUsers": "Usuarios permitidos"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Kirjaudu sisään LDAP-tunnuksella",
|
||||
"loginWithProvider": "Kirjaudu sisään tunnuksella {{name}}",
|
||||
"loading": "Ladataan...",
|
||||
"requiredField": "Tämä kenttä on pakollinen",
|
||||
"username": "LDAP-käyttäjätunnus",
|
||||
"password": "LDAP-salasana",
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Hallitse LDAP-hakemistoja",
|
||||
"description": "Lisää, muuta ja poista LDAP-hakemistoja, joilla käyttäjät voivat kirjautua sisään."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Näytä dokumentit",
|
||||
"none": "LDAP-hakemistoja ei ole määritetty.",
|
||||
"add": "Lisää hakemisto",
|
||||
"edit": "Muokkaa hakemistoa",
|
||||
"delete": "Poista hakemisto",
|
||||
"deleteConfirm": "Poistetaanko tämä hakemisto? Käyttäjät, jotka kirjautuvat sisään sillä, eivät voi kirjautua sisään.",
|
||||
"name": "Näyttönimi",
|
||||
"namePlaceholder": "Yrityshakemisto",
|
||||
"nameRequired": "Näyttönimi on pakollinen",
|
||||
"enabled": "Käytössä",
|
||||
"save": "Tallenna hakemisto",
|
||||
"saving": "Tallennetaan...",
|
||||
"saved": "Tallennettu",
|
||||
"saveFailed": "Tallennus epäonnistui",
|
||||
"deleted": "Poistettu",
|
||||
"deleteFailed": "Poistaminen epäonnistui",
|
||||
"loadFailed": "LDAP-hakemistojen lataaminen epäonnistui",
|
||||
"cancel": "Peruuttaa"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-isäntä",
|
||||
"port": "Portti",
|
||||
"useTls": "Käytä TLS:ää (LDAPS)",
|
||||
"bindDn": "Sido DN",
|
||||
"bindPassword": "Sido salasana",
|
||||
"secretKeep": "Salasana on tallennettu. Jätä tämä tyhjäksi, jos haluat säilyttää sen.",
|
||||
"userSearchBase": "Käyttäjähakupohja",
|
||||
"userSearchFilter": "Käyttäjän hakusuodatin",
|
||||
"usernameAttr": "Käyttäjätunnusattribuutti",
|
||||
"displayNameAttr": "Näyttönimen attribuutti",
|
||||
"groupSearchBase": "Ryhmän hakukanta",
|
||||
"adminGroup": "Hallintaryhmä",
|
||||
"allowedUsers": "Sallitut käyttäjät"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Connexion via LDAP",
|
||||
"loginWithProvider": "Se connecter avec {{name}}",
|
||||
"loading": "Chargement...",
|
||||
"requiredField": "Ce champ est obligatoire",
|
||||
"username": "Nom d'utilisateur LDAP",
|
||||
"password": "Mot de passe LDAP",
|
||||
"signIn": "Se connecter",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gérer les annuaires LDAP",
|
||||
"description": "Ajouter, modifier et supprimer les annuaires LDAP permettant aux utilisateurs de se connecter."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Documentation",
|
||||
"none": "Aucun annuaire LDAP configuré.",
|
||||
"add": "Ajouter un répertoire",
|
||||
"edit": "Modifier le répertoire",
|
||||
"delete": "Supprimer le répertoire",
|
||||
"deleteConfirm": "Supprimer ce répertoire ? Les utilisateurs qui s’y connectent ne pourront plus accéder à leur compte.",
|
||||
"name": "Nom affiché",
|
||||
"namePlaceholder": "Annuaire des entreprises",
|
||||
"nameRequired": "Un nom d'affichage est requis.",
|
||||
"enabled": "Activé",
|
||||
"save": "Répertoire de sauvegarde",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré",
|
||||
"saveFailed": "Échec de l'enregistrement",
|
||||
"deleted": "Supprimé",
|
||||
"deleteFailed": "Échec de la suppression",
|
||||
"loadFailed": "Échec du chargement des répertoires LDAP",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Hôte LDAP",
|
||||
"port": "Port",
|
||||
"useTls": "Utiliser TLS (LDAPS)",
|
||||
"bindDn": "DN de liaison",
|
||||
"bindPassword": "Mot de passe de liaison",
|
||||
"secretKeep": "Un mot de passe est enregistré. Laissez ce champ vide pour le conserver.",
|
||||
"userSearchBase": "Base de recherche utilisateur",
|
||||
"userSearchFilter": "Filtre de recherche utilisateur",
|
||||
"usernameAttr": "Attribut de nom d’utilisateur",
|
||||
"displayNameAttr": "Attribut de nom d’affichage",
|
||||
"groupSearchBase": "Base de recherche de groupe",
|
||||
"adminGroup": "Groupe administrateur",
|
||||
"allowedUsers": "Utilisateurs autorisés"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "כניסה עם LDAP",
|
||||
"loginWithProvider": "התחברות באמצעות {{name}}",
|
||||
"loading": "טְעִינָה...",
|
||||
"requiredField": "שדה זה נדרש",
|
||||
"username": "שם משתמש LDAP",
|
||||
"password": "סיסמת LDAP",
|
||||
"signIn": "כניסה",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ניהול ספריות LDAP",
|
||||
"description": "הוספה, שינוי והסרה של ספריות LDAP שדרכן אנשים יכולים להתחבר."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "הצג מסמכים",
|
||||
"none": "לא הוגדרו ספריות LDAP.",
|
||||
"add": "הוסף ספרייה",
|
||||
"edit": "עריכת ספרייה",
|
||||
"delete": "מחיקת ספרייה",
|
||||
"deleteConfirm": "למחוק את הספרייה הזו? משתמשים שיתחברו דרכה לא יוכלו להתחבר.",
|
||||
"name": "שם תצוגה",
|
||||
"namePlaceholder": "מדריך חברות",
|
||||
"nameRequired": "נדרש שם תצוגה",
|
||||
"enabled": "מופעל",
|
||||
"save": "שמור ספרייה",
|
||||
"saving": "חִסָכוֹן...",
|
||||
"saved": "נשמר",
|
||||
"saveFailed": "השמירה נכשלה",
|
||||
"deleted": "נמחק",
|
||||
"deleteFailed": "המחיקה נכשלה",
|
||||
"loadFailed": "טעינת ספריות LDAP נכשלה",
|
||||
"cancel": "לְבַטֵל"
|
||||
},
|
||||
"fields": {
|
||||
"host": "מארח LDAP",
|
||||
"port": "נָמָל",
|
||||
"useTls": "השתמש ב-TLS (LDAPS)",
|
||||
"bindDn": "קשר DN",
|
||||
"bindPassword": "קשירת סיסמה",
|
||||
"secretKeep": "סיסמה נשמרה. השאר שדה זה ריק כדי לשמור אותה.",
|
||||
"userSearchBase": "בסיס חיפוש משתמשים",
|
||||
"userSearchFilter": "מסנן חיפוש משתמשים",
|
||||
"usernameAttr": "מאפיין שם משתמש",
|
||||
"displayNameAttr": "מאפיין שם תצוגה",
|
||||
"groupSearchBase": "בסיס חיפוש קבוצתי",
|
||||
"adminGroup": "קבוצת ניהול",
|
||||
"allowedUsers": "משתמשים מורשים"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "{{name}} से लॉगिन करें",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"requiredField": "यह फ़ील्ड आवश्यक है",
|
||||
"username": "LDAP उपयोगकर्ता नाम",
|
||||
"password": "LDAP पासवर्ड",
|
||||
"signIn": "साइन इन करें",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "दस्तावेज़ देखें",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "प्रदर्शन नाम",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "सक्षम",
|
||||
"save": "Save Directory",
|
||||
"saving": "सहेजा जा रहा है...",
|
||||
"saved": "सहेजा गया",
|
||||
"saveFailed": "सहेजने में विफल",
|
||||
"deleted": "हटाया गया",
|
||||
"deleteFailed": "हटाने में विफल",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "रद्द करें"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP होस्ट",
|
||||
"port": "पोर्ट",
|
||||
"useTls": "TLS (LDAPS) का उपयोग करें",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind पासवर्ड",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "उपयोगकर्ता खोज आधार",
|
||||
"userSearchFilter": "उपयोगकर्ता खोज फ़िल्टर",
|
||||
"usernameAttr": "उपयोगकर्ता नाम विशेषता",
|
||||
"displayNameAttr": "प्रदर्शन नाम विशेषता",
|
||||
"groupSearchBase": "समूह खोज आधार",
|
||||
"adminGroup": "व्यवस्थापक समूह",
|
||||
"allowedUsers": "अनुमत उपयोगकर्ता"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Bejelentkezés LDAP-pal",
|
||||
"loginWithProvider": "Bejelentkezés {{name}} használatával",
|
||||
"loading": "Terhelés...",
|
||||
"requiredField": "Ez a mező kötelező",
|
||||
"username": "LDAP felhasználónév",
|
||||
"password": "LDAP jelszó",
|
||||
"signIn": "Bejelentkezés",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "LDAP-címtárak kezelése",
|
||||
"description": "LDAP-könyvtárak hozzáadása, módosítása és eltávolítása, amelyekkel a felhasználók bejelentkezhetnek."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Dokumentumok megtekintése",
|
||||
"none": "Nincsenek LDAP könyvtárak konfigurálva.",
|
||||
"add": "Könyvtár hozzáadása",
|
||||
"edit": "Könyvtár szerkesztése",
|
||||
"delete": "Könyvtár törlése",
|
||||
"deleteConfirm": "Törli ezt a könyvtárat? Az ezzel bejelentkező felhasználók nem fognak tudni bejelentkezni.",
|
||||
"name": "Megjelenített név",
|
||||
"namePlaceholder": "Cégjegyzék",
|
||||
"nameRequired": "Megjelenített név megadása kötelező",
|
||||
"enabled": "Engedélyezve",
|
||||
"save": "Könyvtár mentése",
|
||||
"saving": "Megtakarítás...",
|
||||
"saved": "Mentett",
|
||||
"saveFailed": "Nem sikerült menteni",
|
||||
"deleted": "Törölt",
|
||||
"deleteFailed": "Nem sikerült törölni",
|
||||
"loadFailed": "Nem sikerült betölteni az LDAP könyvtárakat",
|
||||
"cancel": "Mégsem"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-gazdagép",
|
||||
"port": "Kikötő",
|
||||
"useTls": "TLS (LDAPS) használata",
|
||||
"bindDn": "Kötési DN",
|
||||
"bindPassword": "Jelszó kötése",
|
||||
"secretKeep": "A jelszó mentésre került. Hagyja ezt üresen a megőrzéshez.",
|
||||
"userSearchBase": "Felhasználói keresési bázis",
|
||||
"userSearchFilter": "Felhasználói keresési szűrő",
|
||||
"usernameAttr": "Felhasználónév attribútum",
|
||||
"displayNameAttr": "Megjelenített név attribútum",
|
||||
"groupSearchBase": "Csoportkeresési bázis",
|
||||
"adminGroup": "Adminisztrátori csoport",
|
||||
"allowedUsers": "Engedélyezett felhasználók"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Accedi con LDAP",
|
||||
"loginWithProvider": "Accedi con {{name}}",
|
||||
"loading": "Caricamento in corso...",
|
||||
"requiredField": "Questo campo è obbligatorio",
|
||||
"username": "Nome utente LDAP",
|
||||
"password": "Password LDAP",
|
||||
"signIn": "Accedi",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestire le directory LDAP",
|
||||
"description": "Aggiungere, modificare e rimuovere le directory LDAP con cui gli utenti possono accedere."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Vedi documenti",
|
||||
"none": "Nessuna directory LDAP configurata.",
|
||||
"add": "Aggiungi directory",
|
||||
"edit": "Modifica directory",
|
||||
"delete": "Elimina la directory",
|
||||
"deleteConfirm": "Eliminare questa directory? Gli utenti che accedono tramite questa directory non potranno più effettuare il login.",
|
||||
"name": "Nome visualizzato",
|
||||
"namePlaceholder": "Elenco delle aziende",
|
||||
"nameRequired": "È richiesto un nome visualizzato",
|
||||
"enabled": "Abilitato",
|
||||
"save": "Salva directory",
|
||||
"saving": "Salvando...",
|
||||
"saved": "Salvato",
|
||||
"saveFailed": "Salvataggio non riuscito",
|
||||
"deleted": "Eliminato",
|
||||
"deleteFailed": "Eliminazione non riuscita",
|
||||
"loadFailed": "Impossibile caricare le directory LDAP",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Host LDAP",
|
||||
"port": "Porta",
|
||||
"useTls": "Usa TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Password di bind",
|
||||
"secretKeep": "È stata salvata una password. Lascia questo campo vuoto per mantenerla.",
|
||||
"userSearchBase": "Base di ricerca utenti",
|
||||
"userSearchFilter": "Filtro di ricerca utenti",
|
||||
"usernameAttr": "Attributo nome utente",
|
||||
"displayNameAttr": "Attributo nome visualizzato",
|
||||
"groupSearchBase": "Base di ricerca gruppi",
|
||||
"adminGroup": "Gruppo amministratori",
|
||||
"allowedUsers": "Utenti consentiti"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "LDAPでサインイン",
|
||||
"loginWithProvider": "{{name}}でログイン",
|
||||
"loading": "読み込み中...",
|
||||
"requiredField": "この項目は必須です",
|
||||
"username": "LDAPユーザー名",
|
||||
"password": "LDAPパスワード",
|
||||
"signIn": "サインイン",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "LDAPディレクトリを管理する",
|
||||
"description": "ユーザーがログインに使用できるLDAPディレクトリを追加、変更、削除します。"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "ドキュメントを見る",
|
||||
"none": "LDAPディレクトリは設定されていません。",
|
||||
"add": "ディレクトリを追加",
|
||||
"edit": "ディレクトリを編集",
|
||||
"delete": "ディレクトリを削除",
|
||||
"deleteConfirm": "このディレクトリを削除しますか?このディレクトリを使用してログインしているユーザーはログインできなくなります。",
|
||||
"name": "表示名",
|
||||
"namePlaceholder": "企業名簿",
|
||||
"nameRequired": "表示名は必須です",
|
||||
"enabled": "有効",
|
||||
"save": "保存先ディレクトリ",
|
||||
"saving": "保存中...",
|
||||
"saved": "保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"deleted": "削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"loadFailed": "LDAPディレクトリの読み込みに失敗しました",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAPホスト",
|
||||
"port": "ポート",
|
||||
"useTls": "TLS (LDAPS) を使用",
|
||||
"bindDn": "バインドDN",
|
||||
"bindPassword": "バインドパスワード",
|
||||
"secretKeep": "パスワードが保存されます。パスワードを保持するには、この欄を空欄のままにしてください。",
|
||||
"userSearchBase": "ユーザー検索ベース",
|
||||
"userSearchFilter": "ユーザー検索フィルター",
|
||||
"usernameAttr": "ユーザー名属性",
|
||||
"displayNameAttr": "表示名属性",
|
||||
"groupSearchBase": "グループ検索ベース",
|
||||
"adminGroup": "管理者グループ",
|
||||
"allowedUsers": "許可ユーザー"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "LDAP으로 로그인",
|
||||
"loginWithProvider": "{{name}}으로 로그인",
|
||||
"loading": "로딩 중...",
|
||||
"requiredField": "필수 입력란입니다",
|
||||
"username": "LDAP 사용자 이름",
|
||||
"password": "LDAP 비밀번호",
|
||||
"signIn": "로그인",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "LDAP 디렉터리 관리",
|
||||
"description": "사용자가 로그인할 수 있는 LDAP 디렉터리를 추가, 변경 및 삭제할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "문서 보기",
|
||||
"none": "LDAP 디렉터리가 구성되지 않았습니다.",
|
||||
"add": "디렉토리 추가",
|
||||
"edit": "디렉토리 편집",
|
||||
"delete": "디렉토리 삭제",
|
||||
"deleteConfirm": "이 디렉토리를 삭제하시겠습니까? 그러면 이 디렉토리로 로그인하는 사용자는 더 이상 로그인할 수 없게 됩니다.",
|
||||
"name": "표시 이름",
|
||||
"namePlaceholder": "회사 디렉토리",
|
||||
"nameRequired": "표시 이름은 필수입니다.",
|
||||
"enabled": "사용",
|
||||
"save": "디렉토리 저장",
|
||||
"saving": "저장 중...",
|
||||
"saved": "저장됨",
|
||||
"saveFailed": "저장 실패",
|
||||
"deleted": "삭제됨",
|
||||
"deleteFailed": "삭제 실패",
|
||||
"loadFailed": "LDAP 디렉터리를 로드하는 데 실패했습니다.",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP 호스트",
|
||||
"port": "포트",
|
||||
"useTls": "TLS 사용 (LDAPS)",
|
||||
"bindDn": "바인딩 DN",
|
||||
"bindPassword": "바인딩 암호",
|
||||
"secretKeep": "비밀번호가 저장됩니다. 비밀번호를 유지하려면 이 필드를 비워 두세요.",
|
||||
"userSearchBase": "사용자 검색 기준",
|
||||
"userSearchFilter": "사용자 검색 필터",
|
||||
"usernameAttr": "사용자 이름 속성",
|
||||
"displayNameAttr": "표시 이름 속성",
|
||||
"groupSearchBase": "그룹 검색 기준",
|
||||
"adminGroup": "관리자 그룹",
|
||||
"allowedUsers": "허용 사용자"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Aanmelden met LDAP",
|
||||
"loginWithProvider": "Inloggen met {{name}}",
|
||||
"loading": "Laden...",
|
||||
"requiredField": "Dit veld is verplicht.",
|
||||
"username": "LDAP-gebruikersnaam",
|
||||
"password": "LDAP-wachtwoord",
|
||||
"signIn": "Inloggen",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "LDAP-directory's beheren",
|
||||
"description": "Voeg LDAP-directory's toe waarmee mensen kunnen inloggen, wijzig ze en verwijder ze."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Documenten bekijken",
|
||||
"none": "Er zijn geen LDAP-directory's geconfigureerd.",
|
||||
"add": "Map toevoegen",
|
||||
"edit": "Map bewerken",
|
||||
"delete": "Map verwijderen",
|
||||
"deleteConfirm": "Als u deze map verwijdert, kunnen gebruikers die zich hiermee aanmelden niet meer inloggen.",
|
||||
"name": "Weergavenaam",
|
||||
"namePlaceholder": "Bedrijfsdirectory",
|
||||
"nameRequired": "Een weergavenaam is verplicht.",
|
||||
"enabled": "Ingeschakeld",
|
||||
"save": "Map opslaan",
|
||||
"saving": "Besparing...",
|
||||
"saved": "Opgeslagen",
|
||||
"saveFailed": "Opslaan mislukt",
|
||||
"deleted": "Verwijderd",
|
||||
"deleteFailed": "Verwijderen mislukt",
|
||||
"loadFailed": "Het laden van de LDAP-directory's is mislukt.",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-host",
|
||||
"port": "Haven",
|
||||
"useTls": "Gebruik TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind wachtwoord",
|
||||
"secretKeep": "Er wordt een wachtwoord opgeslagen. Laat dit veld leeg om het wachtwoord te behouden.",
|
||||
"userSearchBase": "Gebruikerszoekbasis",
|
||||
"userSearchFilter": "Gebruikerszoekfilter",
|
||||
"usernameAttr": "Gebruikersnaamkenmerk",
|
||||
"displayNameAttr": "Weergavenaamkenmerk",
|
||||
"groupSearchBase": "Groepszoekbasis",
|
||||
"adminGroup": "Beheerdersgroep",
|
||||
"allowedUsers": "Toegestane gebruikers"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Laster...",
|
||||
"requiredField": "Dette feltet er obligatorisk",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Aktivert",
|
||||
"save": "Save Directory",
|
||||
"saving": "Lagrer...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Kunne ikke lagre",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Kunne ikke slette",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Zaloguj się za pomocą {{name}}",
|
||||
"loading": "Ładowanie...",
|
||||
"requiredField": "To pole jest wymagane",
|
||||
"username": "Nazwa użytkownika LDAP",
|
||||
"password": "Hasło LDAP",
|
||||
"signIn": "Zaloguj się",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Zobacz dokumentację",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Nazwa wyświetlana",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Włączona",
|
||||
"save": "Save Directory",
|
||||
"saving": "Zapisywanie...",
|
||||
"saved": "Zapisano",
|
||||
"saveFailed": "Nie udało się zapisać",
|
||||
"deleted": "Usunięto",
|
||||
"deleteFailed": "Nie udało się usunąć",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Host LDAP",
|
||||
"port": "Port",
|
||||
"useTls": "Użyj TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Hasło Bind",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "Baza wyszukiwania użytkowników",
|
||||
"userSearchFilter": "Filtr wyszukiwania użytkownika",
|
||||
"usernameAttr": "Atrybut nazwy użytkownika",
|
||||
"displayNameAttr": "Atrybut nazwy wyświetlanej",
|
||||
"groupSearchBase": "Baza wyszukiwania grup",
|
||||
"adminGroup": "Grupa administratorów",
|
||||
"allowedUsers": "Dozwoleni użytkownicy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Carregando...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Documentação",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Salvando...",
|
||||
"saved": "Salvo",
|
||||
"saveFailed": "Falha ao salvar",
|
||||
"deleted": "Apagado",
|
||||
"deleteFailed": "Falha ao apagar",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Porta",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Iniciar sessão com {{name}}",
|
||||
"loading": "A carregar...",
|
||||
"requiredField": "Este campo é obrigatório",
|
||||
"username": "Nome de utilizador LDAP",
|
||||
"password": "Palavra-passe LDAP",
|
||||
"signIn": "Iniciar sessão",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Ver docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Nome de exibição",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Ativado",
|
||||
"save": "Save Directory",
|
||||
"saving": "A guardar...",
|
||||
"saved": "Guardado",
|
||||
"saveFailed": "Falha ao guardar",
|
||||
"deleted": "Eliminado",
|
||||
"deleteFailed": "Falha ao eliminar",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Servidor LDAP",
|
||||
"port": "Porta",
|
||||
"useTls": "Usar TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Palavra-passe de Bind",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "Base de pesquisa de utilizadores",
|
||||
"userSearchFilter": "Filtro de pesquisa de utilizadores",
|
||||
"usernameAttr": "Atributo de nome de utilizador",
|
||||
"displayNameAttr": "Atributo de nome de apresentação",
|
||||
"groupSearchBase": "Base de pesquisa de grupos",
|
||||
"adminGroup": "Grupo de administração",
|
||||
"allowedUsers": "Utilizadores permitidos"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Conectați-vă cu LDAP",
|
||||
"loginWithProvider": "Autentificare cu {{name}}",
|
||||
"loading": "Încărcare...",
|
||||
"requiredField": "Acest câmp este obligatoriu",
|
||||
"username": "Nume de utilizator LDAP",
|
||||
"password": "Parolă LDAP",
|
||||
"signIn": "Conectare",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionarea directoarelor LDAP",
|
||||
"description": "Adăugați, modificați și eliminați directoarele LDAP cu care utilizatorii se pot conecta."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Vizualizați documentele",
|
||||
"none": "Nu sunt configurate directoare LDAP.",
|
||||
"add": "Adăugați directorul",
|
||||
"edit": "Editați directorul",
|
||||
"delete": "Ștergeți directorul",
|
||||
"deleteConfirm": "Ștergeți acest director? Utilizatorii care se conectează cu acesta nu se vor putea conecta.",
|
||||
"name": "Numele de afișare",
|
||||
"namePlaceholder": "Directorul companiilor",
|
||||
"nameRequired": "Este obligatoriu un nume afișat",
|
||||
"enabled": "Activat",
|
||||
"save": "Salvați directorul",
|
||||
"saving": "Economisire...",
|
||||
"saved": "Salvat",
|
||||
"saveFailed": "Salvarea nu a reușit",
|
||||
"deleted": "Șters",
|
||||
"deleteFailed": "Ștergerea nu a reușit",
|
||||
"loadFailed": "Nu s-au putut încărca directoarele LDAP",
|
||||
"cancel": "Anula"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Gazdă LDAP",
|
||||
"port": "Port",
|
||||
"useTls": "Utilizați TLS (LDAPS)",
|
||||
"bindDn": "Legați DN-ul",
|
||||
"bindPassword": "Parolă de legare",
|
||||
"secretKeep": "O parolă este salvată. Lăsați acest câmp gol pentru a o păstra.",
|
||||
"userSearchBase": "Baza de căutare a utilizatorilor",
|
||||
"userSearchFilter": "Filtru de căutare utilizator",
|
||||
"usernameAttr": "Atribut nume utilizator",
|
||||
"displayNameAttr": "Atributul numelui afișat",
|
||||
"groupSearchBase": "Baza de căutare a grupului",
|
||||
"adminGroup": "Grup de administratori",
|
||||
"allowedUsers": "Utilizatori permiși"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Войти через {{name}}",
|
||||
"loading": "Загрузка...",
|
||||
"requiredField": "Обязательное поле",
|
||||
"username": "Имя пользователя LDAP",
|
||||
"password": "Пароль LDAP",
|
||||
"signIn": "Войти",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Документация",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Отображаемое имя",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Включено",
|
||||
"save": "Save Directory",
|
||||
"saving": "Сохранение…",
|
||||
"saved": "Сохранено",
|
||||
"saveFailed": "Не удалось сохранить",
|
||||
"deleted": "Удалено",
|
||||
"deleteFailed": "Не удалось удалить",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP-сервер",
|
||||
"port": "Порт",
|
||||
"useTls": "Использовать TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Пароль привязки",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "База поиска пользователей",
|
||||
"userSearchFilter": "Фильтр поиска пользователей",
|
||||
"usernameAttr": "Атрибут имени пользователя",
|
||||
"displayNameAttr": "Атрибут отображаемого имени",
|
||||
"groupSearchBase": "База поиска групп",
|
||||
"adminGroup": "Группа администраторов",
|
||||
"allowedUsers": "Разрешенные пользователи"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "{{name}} ile giriş yap",
|
||||
"loading": "Yükleniyor...",
|
||||
"requiredField": "Bu alan zorunludur",
|
||||
"username": "LDAP Kullanıcı Adı",
|
||||
"password": "LDAP Parolası",
|
||||
"signIn": "Oturum Aç",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Belgelere bak",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Görünen Ad",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Etkin",
|
||||
"save": "Save Directory",
|
||||
"saving": "Kaydediliyor...",
|
||||
"saved": "Kaydedildi",
|
||||
"saveFailed": "Kaydedilemedi",
|
||||
"deleted": "Silindi",
|
||||
"deleteFailed": "Silinemedi",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "İptal"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Sunucusu",
|
||||
"port": "Port",
|
||||
"useTls": "TLS Kullan (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Parolası",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "Kullanıcı Arama Tabanı",
|
||||
"userSearchFilter": "Kullanıcı Arama Süzgeci",
|
||||
"usernameAttr": "Kullanıcı Adı Özniteliği",
|
||||
"displayNameAttr": "Görünen Ad Özniteliği",
|
||||
"groupSearchBase": "Grup Arama Tabanı",
|
||||
"adminGroup": "Yönetici Grubu",
|
||||
"allowedUsers": "İzin Verilen Kullanıcılar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"loading": "Loading...",
|
||||
"requiredField": "This field is required",
|
||||
"username": "LDAP Username",
|
||||
"password": "LDAP Password",
|
||||
"signIn": "Sign In",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "View docs",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Enabled",
|
||||
"save": "Save Directory",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "User Search Base",
|
||||
"userSearchFilter": "User Search Filter",
|
||||
"usernameAttr": "Username Attribute",
|
||||
"displayNameAttr": "Display Name Attribute",
|
||||
"groupSearchBase": "Group Search Base",
|
||||
"adminGroup": "Admin Group",
|
||||
"allowedUsers": "Allowed Users"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "Đăng nhập bằng {{name}}",
|
||||
"loading": "Đang tải...",
|
||||
"requiredField": "Trường này là bắt buộc",
|
||||
"username": "Tên người dùng LDAP",
|
||||
"password": "Mật khẩu LDAP",
|
||||
"signIn": "Đăng nhập",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "Xem tài liệu",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "Tên hiển thị",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "Đã bật",
|
||||
"save": "Save Directory",
|
||||
"saving": "Đang lưu...",
|
||||
"saved": "Đã lưu",
|
||||
"saveFailed": "Không thể lưu",
|
||||
"deleted": "Đã xóa",
|
||||
"deleteFailed": "Không thể xóa",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "Hủy"
|
||||
},
|
||||
"fields": {
|
||||
"host": "Máy chủ LDAP",
|
||||
"port": "Cổng",
|
||||
"useTls": "Dùng TLS (LDAPS)",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Mật khẩu Bind",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "Cơ sở tìm kiếm người dùng",
|
||||
"userSearchFilter": "Bộ lọc tìm kiếm người dùng",
|
||||
"usernameAttr": "Thuộc tính tên người dùng",
|
||||
"displayNameAttr": "Thuộc tính tên hiển thị",
|
||||
"groupSearchBase": "Cơ sở tìm kiếm nhóm",
|
||||
"adminGroup": "Nhóm Admin",
|
||||
"allowedUsers": "Người dùng được phép"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "使用{{name}}登录",
|
||||
"loading": "加载中...",
|
||||
"requiredField": "此字段为必填项",
|
||||
"username": "LDAP 用户名",
|
||||
"password": "LDAP 密码",
|
||||
"signIn": "登录",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "查看文档",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "显示名称",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "已启用",
|
||||
"save": "Save Directory",
|
||||
"saving": "保存中...",
|
||||
"saved": "已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"deleted": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP 主机",
|
||||
"port": "端口",
|
||||
"useTls": "使用 TLS (LDAPS)",
|
||||
"bindDn": "绑定 DN",
|
||||
"bindPassword": "绑定密码",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "用户搜索基准",
|
||||
"userSearchFilter": "用户搜索过滤器",
|
||||
"usernameAttr": "用户名属性",
|
||||
"displayNameAttr": "显示名称属性",
|
||||
"groupSearchBase": "群组搜索基准",
|
||||
"adminGroup": "管理员组",
|
||||
"allowedUsers": "允许的用户"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"loginWithProvider": "使用 {{name}} 登入",
|
||||
"loading": "載入中...",
|
||||
"requiredField": "此欄位為必填",
|
||||
"username": "LDAP 使用者名稱",
|
||||
"password": "LDAP 密碼",
|
||||
"signIn": "登入",
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage LDAP directories",
|
||||
"description": "Add, change and remove the LDAP directories people can sign in with."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"docsLink": "檢視文件",
|
||||
"none": "No LDAP directories configured.",
|
||||
"add": "Add Directory",
|
||||
"edit": "Edit Directory",
|
||||
"delete": "Delete Directory",
|
||||
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
|
||||
"name": "顯示名稱",
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"enabled": "已啟用",
|
||||
"save": "Save Directory",
|
||||
"saving": "儲存中…",
|
||||
"saved": "已儲存",
|
||||
"saveFailed": "儲存失敗",
|
||||
"deleted": "已刪除",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"loadFailed": "Failed to load LDAP directories",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"fields": {
|
||||
"host": "LDAP 主機",
|
||||
"port": "連接埠",
|
||||
"useTls": "使用 TLS (LDAPS)",
|
||||
"bindDn": "綁定 DN",
|
||||
"bindPassword": "綁定密碼",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
"userSearchBase": "使用者搜尋基底",
|
||||
"userSearchFilter": "使用者搜尋篩選器",
|
||||
"usernameAttr": "使用者名稱屬性",
|
||||
"displayNameAttr": "顯示名稱屬性",
|
||||
"groupSearchBase": "群組搜尋基底",
|
||||
"adminGroup": "管理員群組",
|
||||
"allowedUsers": "允許的使用者"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "ldap",
|
||||
"name": "LDAP",
|
||||
"version": "1.0.0",
|
||||
"description": "Sign in with a username and password checked against an LDAP or Active Directory server.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "Server",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"secrets:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"loginMethods": ["ldap"]
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
"name": "manage",
|
||||
"titleKey": "permissions.manage.title",
|
||||
"descriptionKey": "permissions.manage.description",
|
||||
"defaultRoles": ["admin"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "providers",
|
||||
"type": "custom",
|
||||
"component": "providers",
|
||||
"permission": "manage"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"desktop": "server"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ldap 0001: create_providers
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_ldap_providers` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` text NOT NULL,
|
||||
`enabled` boolean NOT NULL DEFAULT true,
|
||||
`display_order` int NOT NULL DEFAULT 0,
|
||||
`config` text NOT NULL,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ldap 0001: create_providers
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"config" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"providers": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": true
|
||||
},
|
||||
"displayOrder": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ldap 0001: create_providers
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"name" text NOT NULL,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"config" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@termix-plugin/ldap",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"ldapjs": "^3.0.7",
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { providers } from "./tables.js";
|
||||
import { createProviderStore } from "./providers.js";
|
||||
import { createLdapLogin, METHOD_ID } from "./ldap.js";
|
||||
import { registerLdapRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(providers);
|
||||
const store = createProviderStore(ctx, table);
|
||||
|
||||
// One form per enabled directory.
|
||||
ctx.auth.registerLoginMethod({
|
||||
id: METHOD_ID,
|
||||
labelKey: "loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await store.listRows()).map((row) => ({
|
||||
id: String(row.id),
|
||||
label: row.name,
|
||||
enabled: !!row.enabled,
|
||||
type: "ldap",
|
||||
})),
|
||||
verify: createLdapLogin(ctx, store),
|
||||
});
|
||||
|
||||
registerLdapRoutes(ctx.http.router<Router>(), ctx, store);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* LDAP sign-in as a form login method: a service bind to find the user, a
|
||||
* bind as the user to check the password, then an optional admin group
|
||||
* lookup. Core finds or provisions the user and issues the session.
|
||||
*/
|
||||
|
||||
import ldap from "ldapjs";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
type PluginLoginRequest,
|
||||
type PluginVerifiedIdentity,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { ProviderStore } from "./providers.js";
|
||||
import type { LdapConfig } from "./types.js";
|
||||
|
||||
export const METHOD_ID = "ldap";
|
||||
|
||||
export function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The provider id identities from this directory are stored under. */
|
||||
export function identityProvider(providerId: number): string {
|
||||
return `ldap:${providerId}`;
|
||||
}
|
||||
|
||||
function createClient(config: LdapConfig): ldap.Client {
|
||||
const useTLS = !!config.useTLS;
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${config.host}:${config.port || 389}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function bind(client: ldap.Client, dn: string, password: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function search(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue(entry: ldap.SearchEntry, key: string): string {
|
||||
const attr = entry.attributes.find((a) => a.type === key);
|
||||
if (!attr) return "";
|
||||
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
|
||||
}
|
||||
|
||||
export function createLdapLogin(ctx: PluginContext, store: ProviderStore) {
|
||||
return async function verify(
|
||||
request: PluginLoginRequest,
|
||||
instanceId: string | null,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const body = request.body ?? {};
|
||||
const providerId = Number(instanceId ?? body.providerId);
|
||||
const username = typeof body.username === "string" ? body.username : "";
|
||||
const password = typeof body.password === "string" ? body.password : "";
|
||||
|
||||
if (!Number.isInteger(providerId) || !username || !password) {
|
||||
throw new LoginMethodError(
|
||||
"providerId, username, and password are required",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Same limits as password login: an LDAP bind is a password guess.
|
||||
const clientIp = request.ip || "unknown";
|
||||
const rateLimitKey = `${providerId}:${username}`;
|
||||
const lock = await ctx.auth.loginRateLimit.isLocked(clientIp, rateLimitKey);
|
||||
if (lock.locked) {
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lock.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const provider = await store.find(providerId);
|
||||
if (!provider || !provider.enabled) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
const config = provider.config;
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
throw new LoginMethodError("LDAP provider is misconfigured", 500);
|
||||
}
|
||||
|
||||
const refuse = async (reason: string) => {
|
||||
await ctx.auth.loginRateLimit.recordFailure(clientIp, rateLimitKey);
|
||||
ctx.log.warn(`LDAP login refused for ${username}: ${reason}`);
|
||||
return new LoginMethodError("Invalid username or password", 401);
|
||||
};
|
||||
|
||||
const serviceClient = createClient(config);
|
||||
try {
|
||||
await bind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const entries = await search(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
[
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
"distinguishedName",
|
||||
],
|
||||
);
|
||||
if (entries.length === 0) throw await refuse("user not found");
|
||||
|
||||
const userEntry = entries[0];
|
||||
// AD supplies its DN as an attribute; avoid re-serializing it through ldapjs.
|
||||
const userDN =
|
||||
firstValue(userEntry, "distinguishedName") || userEntry.dn.toString();
|
||||
const uid =
|
||||
firstValue(userEntry, config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
|
||||
const email =
|
||||
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
|
||||
|
||||
const userClient = createClient(config);
|
||||
try {
|
||||
await bind(userClient, userDN, password);
|
||||
} catch {
|
||||
throw await refuse("wrong password");
|
||||
} finally {
|
||||
unbind(userClient);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
isAdmin = false;
|
||||
try {
|
||||
const groups = await search(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
`(member=${ldapEscapeFilter(userDN)})`,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groups.some(
|
||||
(group) =>
|
||||
firstValue(group, "cn") === config.adminGroup ||
|
||||
group.dn.toString() === config.adminGroup,
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`LDAP group check failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: identityProvider(providerId),
|
||||
subject: uid,
|
||||
email: email || null,
|
||||
name: displayName,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowedUsers ?? null,
|
||||
legacy: {
|
||||
identifier: `ldap:${providerId}:${uid}`,
|
||||
providerRowId: providerId,
|
||||
},
|
||||
rememberMe: !!body.rememberMe,
|
||||
rateLimitKey,
|
||||
};
|
||||
} finally {
|
||||
unbind(serviceClient);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { LdapConfig, ProviderRow } from "./types.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const LEGACY_PREFIXES = ["encoded:", "encrypted:"];
|
||||
|
||||
export type ProviderStore = ReturnType<typeof createProviderStore>;
|
||||
|
||||
export function createProviderStore(ctx: PluginContext, table: Table) {
|
||||
const client = () => ctx.db.client<Drizzle>();
|
||||
|
||||
/** Opens the bind password: sealed, or the 2.8 base64 forms. */
|
||||
async function openSecret(value: string): Promise<string> {
|
||||
const prefix = LEGACY_PREFIXES.find((p) => value.startsWith(p));
|
||||
if (prefix) {
|
||||
return Buffer.from(value.slice(prefix.length), "base64").toString("utf8");
|
||||
}
|
||||
return (await ctx.secrets.unseal(value)) ?? value;
|
||||
}
|
||||
|
||||
async function parseConfig(raw: string): Promise<LdapConfig> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
if (typeof config.bindPassword === "string" && config.bindPassword) {
|
||||
config.bindPassword = await openSecret(config.bindPassword);
|
||||
}
|
||||
return config as unknown as LdapConfig;
|
||||
}
|
||||
|
||||
async function sealConfig(config: Record<string, unknown>): Promise<string> {
|
||||
const out = { ...config };
|
||||
if (typeof out.bindPassword === "string" && out.bindPassword) {
|
||||
out.bindPassword = await ctx.secrets.seal(out.bindPassword);
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function findRow(id: number): Promise<ProviderRow | null> {
|
||||
const drizzle = await client();
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.limit(1)) as ProviderRow[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
parseConfig,
|
||||
findRow,
|
||||
|
||||
async listRows(): Promise<ProviderRow[]> {
|
||||
const drizzle = await client();
|
||||
return (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.orderBy(asc(table.displayOrder), asc(table.id))) as ProviderRow[];
|
||||
},
|
||||
|
||||
async find(id: number): Promise<{
|
||||
row: ProviderRow;
|
||||
enabled: boolean;
|
||||
config: LdapConfig;
|
||||
} | null> {
|
||||
const row = await findRow(id);
|
||||
if (!row) return null;
|
||||
return {
|
||||
row,
|
||||
enabled: !!row.enabled,
|
||||
config: await parseConfig(row.config),
|
||||
};
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
}): Promise<ProviderRow> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
await drizzle.insert(table).values({
|
||||
name: input.name,
|
||||
enabled: input.enabled,
|
||||
displayOrder: input.displayOrder,
|
||||
config: await sealConfig(input.config),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.persist();
|
||||
// No returning() on every engine: find the row by what was written.
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.name, input.name), eq(table.createdAt, now)))
|
||||
.orderBy(desc(table.id))
|
||||
.limit(1)) as ProviderRow[];
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
values: Partial<{
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
}>,
|
||||
): Promise<ProviderRow | null> {
|
||||
const drizzle = await client();
|
||||
const { config, ...rest } = values;
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({
|
||||
...rest,
|
||||
...(config ? { config: await sealConfig(config) } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(table.id, id));
|
||||
await ctx.db.persist();
|
||||
return findRow(id);
|
||||
},
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.id, id));
|
||||
await ctx.db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { identityProvider } from "./ldap.js";
|
||||
import type { ProviderStore } from "./providers.js";
|
||||
import { REQUIRED_FIELDS, type ProviderRow } from "./types.js";
|
||||
|
||||
function fail(
|
||||
ctx: PluginContext,
|
||||
res: Response,
|
||||
message: string,
|
||||
error: unknown,
|
||||
) {
|
||||
ctx.log.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
|
||||
function missingFields(config: Record<string, unknown>): string[] {
|
||||
return REQUIRED_FIELDS.filter((field) => !config[field]);
|
||||
}
|
||||
|
||||
export function registerLdapRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
store: ProviderStore,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("manage") as never);
|
||||
|
||||
async function present(row: ProviderRow) {
|
||||
const { bindPassword, ...config } = (await store.parseConfig(
|
||||
row.config,
|
||||
)) as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
enabled: !!row.enabled,
|
||||
displayOrder: row.displayOrder,
|
||||
config,
|
||||
hasBindPassword: typeof bindPassword === "string" && !!bindPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers:
|
||||
* get:
|
||||
* summary: List LDAP directories
|
||||
* description: Every configured LDAP directory. The bind password is never sent, only whether one is set. Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directories.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
*/
|
||||
router.get("/providers", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = await store.listRows();
|
||||
res.json({ providers: await Promise.all(rows.map(present)) });
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to list LDAP providers", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers:
|
||||
* post:
|
||||
* summary: Add an LDAP directory
|
||||
* description: Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Directory added.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
*/
|
||||
router.post("/providers", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
enabled = true,
|
||||
displayOrder = 0,
|
||||
config = {},
|
||||
} = req.body ?? {};
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Provider name is required" });
|
||||
return;
|
||||
}
|
||||
const missing = missingFields(config);
|
||||
if (missing.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const row = await store.create({
|
||||
name: name.trim(),
|
||||
enabled: !!enabled,
|
||||
displayOrder: Number(displayOrder) || 0,
|
||||
config: { ...(config as Record<string, unknown>) },
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "ldap_provider_create",
|
||||
resourceType: "ldap_provider",
|
||||
resourceId: String(row.id),
|
||||
resourceName: row.name,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json(await present(row));
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to create LDAP provider", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers/{id}:
|
||||
* put:
|
||||
* summary: Update an LDAP directory
|
||||
* description: Config fields are merged over the stored ones; an empty bind password keeps the stored one. Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directory updated.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
* 404:
|
||||
* description: Directory not found.
|
||||
*/
|
||||
router.put("/providers/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.params.id), 10);
|
||||
const row = Number.isInteger(id) ? await store.findRow(id) : null;
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Provider not found" });
|
||||
return;
|
||||
}
|
||||
const { name, enabled, displayOrder, config } = req.body ?? {};
|
||||
const values: Parameters<ProviderStore["update"]>[1] = {};
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Provider name is required" });
|
||||
return;
|
||||
}
|
||||
values.name = name.trim();
|
||||
}
|
||||
if (enabled !== undefined) values.enabled = !!enabled;
|
||||
if (displayOrder !== undefined) {
|
||||
values.displayOrder = Number(displayOrder) || 0;
|
||||
}
|
||||
if (config && typeof config === "object") {
|
||||
const incoming = { ...(config as Record<string, unknown>) };
|
||||
if (!incoming.bindPassword) delete incoming.bindPassword;
|
||||
const merged = {
|
||||
...((await store.parseConfig(row.config)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
...incoming,
|
||||
};
|
||||
const missing = missingFields(merged);
|
||||
if (missing.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
values.config = merged;
|
||||
}
|
||||
const updated = await store.update(id, values);
|
||||
await ctx.audit.record({
|
||||
action: "ldap_provider_update",
|
||||
resourceType: "ldap_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: updated?.name ?? row.name,
|
||||
success: true,
|
||||
});
|
||||
res.json(await present(updated ?? row));
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to update LDAP provider", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete an LDAP directory
|
||||
* description: Refused while users still sign in with it. Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directory deleted.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
* 404:
|
||||
* description: Directory not found.
|
||||
* 409:
|
||||
* description: Users are still linked to it.
|
||||
*/
|
||||
router.delete("/providers/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.params.id), 10);
|
||||
const row = Number.isInteger(id) ? await store.findRow(id) : null;
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Provider not found" });
|
||||
return;
|
||||
}
|
||||
if ((await ctx.auth.countLinkedUsers(identityProvider(id))) > 0) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
"Users still sign in with this directory. Disable it instead, or remove those users first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await store.remove(id);
|
||||
await ctx.audit.record({
|
||||
action: "ldap_provider_delete",
|
||||
resourceType: "ldap_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: row.name,
|
||||
success: true,
|
||||
});
|
||||
res.json({ message: "LDAP provider deleted" });
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to delete LDAP provider", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* LDAP directories. Rows from 2.8 are copied in from sso_providers by core's
|
||||
* boot migration, keeping their ids, because identities point at them.
|
||||
*/
|
||||
export const providers = defineTable("providers", {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
displayOrder: integer().notNull().default(0),
|
||||
config: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const tables = [providers];
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface LdapConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
useTLS: boolean;
|
||||
bindDN: string;
|
||||
bindPassword: string;
|
||||
userSearchBase: string;
|
||||
userSearchFilter: string;
|
||||
usernameAttribute: string;
|
||||
displayNameAttribute: string;
|
||||
groupSearchBase?: string;
|
||||
adminGroup?: string;
|
||||
allowedUsers?: string;
|
||||
}
|
||||
|
||||
export interface ProviderRow {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const REQUIRED_FIELDS = [
|
||||
"host",
|
||||
"port",
|
||||
"bindDN",
|
||||
"bindPassword",
|
||||
"userSearchBase",
|
||||
"userSearchFilter",
|
||||
"usernameAttribute",
|
||||
] as const;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type LoginMethodUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const labelClass =
|
||||
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
/** A collapsible username and password form per enabled directory. */
|
||||
export function LdapLoginForms({
|
||||
instances,
|
||||
disabled,
|
||||
submit,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
}, [expandedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => {
|
||||
const expanded = expandedId === instance.id;
|
||||
return (
|
||||
<div key={instance.id} className="flex flex-col border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expanded ? null : instance.id)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<span>{t("loginWithProvider", { name: instance.label })}</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
toast.error(t("requiredField"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await submit(
|
||||
{ username: username.trim(), password },
|
||||
instance.id,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-3 p-3 border-t border-border"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>{t("username")}</span>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>{t("password")}</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className="font-mono"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? t("loading") : t("signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Switch,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { createLdapApi, type LdapProvider } from "./ldap-api";
|
||||
|
||||
type Fields = {
|
||||
host: string;
|
||||
port: string;
|
||||
useTLS: boolean;
|
||||
bindDN: string;
|
||||
bindPassword: string;
|
||||
userSearchBase: string;
|
||||
userSearchFilter: string;
|
||||
usernameAttribute: string;
|
||||
displayNameAttribute: string;
|
||||
groupSearchBase: string;
|
||||
adminGroup: string;
|
||||
allowedUsers: string;
|
||||
};
|
||||
|
||||
const EMPTY_FIELDS: Fields = {
|
||||
host: "",
|
||||
port: "389",
|
||||
useTLS: false,
|
||||
bindDN: "",
|
||||
bindPassword: "",
|
||||
userSearchBase: "",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
usernameAttribute: "uid",
|
||||
displayNameAttribute: "cn",
|
||||
groupSearchBase: "",
|
||||
adminGroup: "",
|
||||
allowedUsers: "",
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return err.response?.data?.error || err.message || fallback;
|
||||
}
|
||||
|
||||
const labelClass =
|
||||
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className={labelClass}>
|
||||
{label}
|
||||
{required && <span className="text-accent-brand ml-1">*</span>}
|
||||
</label>
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider: LdapProvider | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const api = createLdapApi(usePluginApi());
|
||||
const [name, setName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [fields, setFields] = useState<Fields>(EMPTY_FIELDS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(provider?.name ?? "");
|
||||
setEnabled(provider?.enabled ?? true);
|
||||
const config = provider?.config ?? {};
|
||||
const next = { ...EMPTY_FIELDS };
|
||||
for (const key of Object.keys(next) as Array<keyof Fields>) {
|
||||
const value = config[key];
|
||||
if (key === "useTLS") next.useTLS = value === true;
|
||||
else if (key === "port" && value !== undefined) next.port = String(value);
|
||||
else if (typeof value === "string") next[key] = value as never;
|
||||
}
|
||||
next.bindPassword = "";
|
||||
setFields(next);
|
||||
}, [open, provider]);
|
||||
|
||||
const set = (key: Exclude<keyof Fields, "useTLS">) => (value: string) =>
|
||||
setFields((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("providers.nameRequired"));
|
||||
return;
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
host: fields.host.trim(),
|
||||
port: Number.parseInt(fields.port, 10) || 389,
|
||||
useTLS: fields.useTLS,
|
||||
bindDN: fields.bindDN.trim(),
|
||||
userSearchBase: fields.userSearchBase.trim(),
|
||||
userSearchFilter: fields.userSearchFilter.trim(),
|
||||
usernameAttribute: fields.usernameAttribute.trim() || "uid",
|
||||
displayNameAttribute: fields.displayNameAttribute.trim() || "cn",
|
||||
groupSearchBase: fields.groupSearchBase.trim() || undefined,
|
||||
adminGroup: fields.adminGroup.trim() || undefined,
|
||||
allowedUsers: fields.allowedUsers.trim() || undefined,
|
||||
};
|
||||
if (fields.bindPassword) config.bindPassword = fields.bindPassword;
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { name: name.trim(), enabled, config };
|
||||
if (provider) await api.update(provider.id, input);
|
||||
else await api.create(input);
|
||||
toast.success(t("providers.saved"));
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.saveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{provider ? t("providers.edit") : t("providers.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Field label={t("providers.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("providers.namePlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={labelClass}>{t("providers.enabled")}</label>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/ldap"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("providers.docsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("fields.host")} required>
|
||||
<Input
|
||||
value={fields.host}
|
||||
onChange={(e) => set("host")(e.target.value)}
|
||||
placeholder="ldap.example.com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Field label={t("fields.port")} required>
|
||||
<Input
|
||||
value={fields.port}
|
||||
onChange={(e) => set("port")(e.target.value)}
|
||||
placeholder="389"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-end pb-1.5 gap-2">
|
||||
<Switch
|
||||
checked={fields.useTLS}
|
||||
onCheckedChange={(useTLS) =>
|
||||
setFields((prev) => ({ ...prev, useTLS }))
|
||||
}
|
||||
aria-label={t("fields.useTls")}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("fields.useTls")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("fields.bindDn")} required>
|
||||
<Input
|
||||
value={fields.bindDN}
|
||||
onChange={(e) => set("bindDN")(e.target.value)}
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("fields.bindPassword")}
|
||||
required={!provider?.hasBindPassword}
|
||||
hint={
|
||||
provider?.hasBindPassword ? t("fields.secretKeep") : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={fields.bindPassword}
|
||||
onChange={(e) => set("bindPassword")(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.userSearchBase")} required>
|
||||
<Input
|
||||
value={fields.userSearchBase}
|
||||
onChange={(e) => set("userSearchBase")(e.target.value)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.userSearchFilter")} required>
|
||||
<Input
|
||||
value={fields.userSearchFilter}
|
||||
onChange={(e) => set("userSearchFilter")(e.target.value)}
|
||||
placeholder="(uid={{username}})"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.usernameAttr")} required>
|
||||
<Input
|
||||
value={fields.usernameAttribute}
|
||||
onChange={(e) => set("usernameAttribute")(e.target.value)}
|
||||
placeholder="uid"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.displayNameAttr")} required>
|
||||
<Input
|
||||
value={fields.displayNameAttribute}
|
||||
onChange={(e) => set("displayNameAttribute")(e.target.value)}
|
||||
placeholder="cn"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="border-t border-border pt-3 flex flex-col gap-3">
|
||||
<Field label={t("fields.groupSearchBase")}>
|
||||
<Input
|
||||
value={fields.groupSearchBase}
|
||||
onChange={(e) => set("groupSearchBase")(e.target.value)}
|
||||
placeholder="ou=groups,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.adminGroup")}>
|
||||
<Input
|
||||
value={fields.adminGroup}
|
||||
onChange={(e) => set("adminGroup")(e.target.value)}
|
||||
placeholder="cn=admins,ou=groups,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.allowedUsers")}>
|
||||
<Input
|
||||
value={fields.allowedUsers}
|
||||
onChange={(e) => set("allowedUsers")(e.target.value)}
|
||||
placeholder="user1,user2,@domain.com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("providers.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t("providers.saving") : t("providers.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "providers" custom field on the plugin's admin settings page: the
|
||||
* directory list with an editor.
|
||||
*/
|
||||
export function ProvidersSetting() {
|
||||
const { t } = useTranslation();
|
||||
const api = createLdapApi(usePluginApi());
|
||||
const [providers, setProviders] = useState<LdapProvider[] | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LdapProvider | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setProviders(await api.list());
|
||||
} catch (error) {
|
||||
setProviders([]);
|
||||
toast.error(errorMessage(error, t("providers.loadFailed")));
|
||||
}
|
||||
// api is rebuilt each render from the same client.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function toggleEnabled(provider: LdapProvider) {
|
||||
try {
|
||||
await api.update(provider.id, { enabled: !provider.enabled });
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.saveFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(provider: LdapProvider) {
|
||||
if (!window.confirm(t("providers.deleteConfirm"))) return;
|
||||
try {
|
||||
await api.remove(provider.id);
|
||||
toast.success(t("providers.deleted"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.deleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
if (providers === null) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.none")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center gap-2 p-2 border border-border bg-background"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={() => void toggleEnabled(provider)}
|
||||
aria-label={t("providers.enabled")}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditing(provider);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
title={t("providers.edit")}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void remove(provider)}
|
||||
title={t("providers.delete")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("providers.add")}
|
||||
</Button>
|
||||
<ProviderDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
provider={editing}
|
||||
onSaved={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { LdapLoginForms } from "./LdapLoginForms";
|
||||
import { ProvidersSetting } from "./ProvidersSetting";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerLoginMethod({
|
||||
id: "ldap",
|
||||
titleKey: "loginWithLdap",
|
||||
component: LdapLoginForms,
|
||||
});
|
||||
app.registerSettingsComponent("providers", ProvidersSetting);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface LdapProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
hasBindPassword: boolean;
|
||||
}
|
||||
|
||||
export interface LdapProviderInput {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Paths are relative to /plugin-api/ldap/. */
|
||||
export function createLdapApi(api: PluginApiClient) {
|
||||
return {
|
||||
async list() {
|
||||
return (await api.get<{ providers: LdapProvider[] }>("/providers")).data
|
||||
.providers;
|
||||
},
|
||||
async create(input: LdapProviderInput) {
|
||||
return (await api.post<LdapProvider>("/providers", input)).data;
|
||||
},
|
||||
async update(id: number, input: LdapProviderInput) {
|
||||
return (await api.put<LdapProvider>(`/providers/${id}`, input)).data;
|
||||
},
|
||||
async remove(id: number) {
|
||||
await api.delete(`/providers/${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
user?: string;
|
||||
body?: unknown;
|
||||
form?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
// 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; location: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
linkedUsers?: Record<string, number>;
|
||||
loginAttemptLimit?: number;
|
||||
before?: (sqlite: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, 1)")
|
||||
.run("admin", "admin");
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
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,
|
||||
async request(
|
||||
method,
|
||||
path,
|
||||
{ user = "admin", body, form, headers = {} } = {},
|
||||
) {
|
||||
const payload =
|
||||
form !== undefined
|
||||
? new URLSearchParams(form).toString()
|
||||
: body !== undefined && method !== "GET"
|
||||
? JSON.stringify(body)
|
||||
: undefined;
|
||||
const response = await realFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(form !== undefined
|
||||
? { "content-type": "application/x-www-form-urlencoded" }
|
||||
: payload !== undefined
|
||||
? { "content-type": "application/json" }
|
||||
: {}),
|
||||
...headers,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
location: response.headers.get("location"),
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers.js";
|
||||
|
||||
const directory = vi.hoisted(() => ({
|
||||
users: {} as Record<
|
||||
string,
|
||||
{ dn: string; password: string; attrs: Record<string, string> }
|
||||
>,
|
||||
adminDns: [] as string[],
|
||||
servicePassword: "svc",
|
||||
binds: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("ldapjs", async () => {
|
||||
const { default: ldap } =
|
||||
await vi.importActual<typeof import("ldapjs")>("ldapjs");
|
||||
function createClient() {
|
||||
return {
|
||||
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
|
||||
directory.binds.push(dn);
|
||||
if (dn === "cn=service" && password === directory.servicePassword) {
|
||||
return cb();
|
||||
}
|
||||
const user = Object.values(directory.users).find((u) => u.dn === dn);
|
||||
if (user && user.password === password) return cb();
|
||||
cb(new Error("invalid credentials"));
|
||||
},
|
||||
search: (
|
||||
base: string,
|
||||
options: { filter: string; attributes: string[] },
|
||||
cb: (err: Error | null, res: unknown) => void,
|
||||
) => {
|
||||
const listeners: Record<string, (value?: unknown) => void> = {};
|
||||
cb(null, {
|
||||
on: (event: string, fn: (value?: unknown) => void) => {
|
||||
listeners[event] = fn;
|
||||
},
|
||||
});
|
||||
queueMicrotask(() => {
|
||||
if (base === "ou=groups") {
|
||||
for (const dn of directory.adminDns) {
|
||||
if (options.filter.includes(dn)) {
|
||||
listeners.searchEntry?.({
|
||||
dn: { toString: () => "cn=admins,ou=groups" },
|
||||
attributes: [{ type: "cn", values: ["admins"] }],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const match = /uid=([^)]+)/.exec(options.filter);
|
||||
const user = match ? directory.users[match[1]] : undefined;
|
||||
if (user) {
|
||||
listeners.searchEntry?.({
|
||||
dn: ldap.parseDN(user.dn),
|
||||
attributes: Object.entries(user.attrs)
|
||||
.filter(([type]) => options.attributes.includes(type))
|
||||
.map(([type, value]) => ({
|
||||
type,
|
||||
values: [value],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
listeners.end?.();
|
||||
});
|
||||
},
|
||||
unbind: () => {},
|
||||
};
|
||||
}
|
||||
return { default: { createClient }, createClient };
|
||||
});
|
||||
|
||||
const CONFIG = {
|
||||
host: "ldap.example",
|
||||
port: 389,
|
||||
bindDN: "cn=service",
|
||||
bindPassword: "svc",
|
||||
userSearchBase: "ou=people",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
usernameAttribute: "uid",
|
||||
displayNameAttribute: "cn",
|
||||
groupSearchBase: "ou=groups",
|
||||
adminGroup: "admins",
|
||||
allowedUsers: "@example.com",
|
||||
};
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
directory.users = {
|
||||
bob: {
|
||||
dn: "uid=bob,ou=people",
|
||||
password: "hunter2",
|
||||
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
|
||||
},
|
||||
};
|
||||
directory.adminDns = [];
|
||||
directory.binds = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function withDirectory(options: Parameters<typeof startServer>[0] = {}) {
|
||||
server = await startServer(options);
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: { name: "Corp LDAP", config: CONFIG },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
return { s: server, id: created.body.id as number };
|
||||
}
|
||||
|
||||
function verify(
|
||||
s: TestServer,
|
||||
instance: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const method = s.mock.auth.loginMethods[0];
|
||||
return method.verify!(
|
||||
{ body, query: {}, headers: {}, ip: "10.0.0.1" },
|
||||
instance,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LDAP sign-in", () => {
|
||||
it.each([
|
||||
"CN=Bob,OU=Первый Департамент,DC=example,DC=com",
|
||||
String.raw`CN=Bob\, Builder,OU=Первый Департамент,DC=example,DC=com`,
|
||||
String.raw`CN=Bob\5cBuilder,OU=People,DC=example,DC=com`,
|
||||
])(
|
||||
"binds with the directory's original distinguishedName: %s",
|
||||
async (dn) => {
|
||||
const { s, id } = await withDirectory();
|
||||
directory.users.bob.dn = dn;
|
||||
directory.users.bob.attrs.distinguishedName = dn;
|
||||
const identity = await verify(s, String(id), {
|
||||
username: "bob",
|
||||
password: "hunter2",
|
||||
});
|
||||
expect(identity).toMatchObject({ subject: "bob" });
|
||||
expect(directory.binds).toEqual(["cn=service", dn]);
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "wrong" }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
},
|
||||
);
|
||||
|
||||
it("binds as the service, then as the user, and returns the identity", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
directory.adminDns = ["uid=bob,ou=people"];
|
||||
const identity = await verify(s, String(id), {
|
||||
username: "bob",
|
||||
password: "hunter2",
|
||||
rememberMe: true,
|
||||
});
|
||||
expect(identity).toEqual({
|
||||
kind: "external",
|
||||
provider: `ldap:${id}`,
|
||||
subject: "bob",
|
||||
email: "bob@example.com",
|
||||
name: "Bob Builder",
|
||||
isAdmin: true,
|
||||
allowedUsers: "@example.com",
|
||||
legacy: { identifier: `ldap:${id}:bob`, providerRowId: id },
|
||||
rememberMe: true,
|
||||
rateLimitKey: `${id}:bob`,
|
||||
});
|
||||
expect(directory.binds).toEqual(["cn=service", "uid=bob,ou=people"]);
|
||||
});
|
||||
|
||||
it("is not an admin when the directory says so", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
const identity = await verify(s, String(id), {
|
||||
username: "bob",
|
||||
password: "hunter2",
|
||||
});
|
||||
expect(identity).toMatchObject({ isAdmin: false });
|
||||
});
|
||||
|
||||
it("refuses a wrong password and an unknown user the same way", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "nope" }),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "Invalid username or password",
|
||||
});
|
||||
await expect(
|
||||
verify(s, String(id), { username: "mallory", password: "x" }),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "Invalid username or password",
|
||||
});
|
||||
expect(s.mock.auth.loginFailures.get(`10.0.0.1|${id}:bob`)).toBe(1);
|
||||
});
|
||||
|
||||
it("locks out after too many failures, like password login", async () => {
|
||||
const { s, id } = await withDirectory({ loginAttemptLimit: 2 });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "nope" }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
}
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it("escapes the username in the search filter", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob)(uid=*", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("refuses a disabled or unknown directory", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
await s.request("PUT", `/providers/${id}`, { body: { enabled: false } });
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
await expect(
|
||||
verify(s, "999", { username: "bob", password: "hunter2" }),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([
|
||||
{ id: String(id), label: "Corp LDAP", enabled: false, type: "ldap" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("needs a directory, a username and a password", async () => {
|
||||
const { s } = await withDirectory();
|
||||
await expect(
|
||||
verify(s, null as never, { username: "bob" }),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory admin routes", () => {
|
||||
it("seal the bind password and never send it back", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
const stored = s.db.sqlite
|
||||
.prepare("SELECT config FROM p_ldap_providers WHERE id = ?")
|
||||
.get(id) as { config: string };
|
||||
expect(JSON.parse(stored.config).bindPassword).toMatch(/^sealed:/);
|
||||
|
||||
const listed = await s.request("GET", "/providers");
|
||||
expect(listed.body.providers[0]).toMatchObject({
|
||||
name: "Corp LDAP",
|
||||
hasBindPassword: true,
|
||||
});
|
||||
expect(listed.body.providers[0].config.bindPassword).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keep the bind password when an edit leaves it empty", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
const updated = await s.request("PUT", `/providers/${id}`, {
|
||||
body: { config: { host: "ldap2.example", bindPassword: "" } },
|
||||
});
|
||||
expect(updated.body).toMatchObject({
|
||||
hasBindPassword: true,
|
||||
config: { host: "ldap2.example" },
|
||||
});
|
||||
await expect(
|
||||
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
||||
).resolves.toMatchObject({ subject: "bob" });
|
||||
});
|
||||
|
||||
it("reject a directory with missing fields", async () => {
|
||||
server = await startServer();
|
||||
const response = await server.request("POST", "/providers", {
|
||||
body: { name: "X", config: { host: "a" } },
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("will not delete a directory people still sign in with", async () => {
|
||||
const { s, id } = await withDirectory({
|
||||
linkedUsers: { "ldap:1": 1 },
|
||||
});
|
||||
expect(id).toBe(1);
|
||||
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it("delete a directory nobody uses", async () => {
|
||||
const { s, id } = await withDirectory();
|
||||
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(200);
|
||||
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([]);
|
||||
});
|
||||
|
||||
it("need ldap.manage", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
for (const [method, path] of [
|
||||
["GET", "/providers"],
|
||||
["POST", "/providers"],
|
||||
["PUT", "/providers/1"],
|
||||
["DELETE", "/providers/1"],
|
||||
]) {
|
||||
expect((await server.request(method, path, { body: {} })).status).toBe(
|
||||
403,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it.each(["db:own", "auth:provide", "network:serve"])(
|
||||
"activate fails closed without %s",
|
||||
async (capability) => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot read a bind password without secrets:own", async () => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
server = await startServer({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
|
||||
});
|
||||
const response = await server.request("POST", "/providers", {
|
||||
body: { name: "Corp LDAP", config: CONFIG },
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
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;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the login form and the directory settings", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.loginMethods()).toEqual(["ldap"]);
|
||||
expect(rendered.registered.settingsComponents()).toEqual(["providers"]);
|
||||
});
|
||||
|
||||
it("submits the username and password for the chosen directory", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const submit = vi.fn(async () => {});
|
||||
rendered.renderLoginMethod("ldap", {
|
||||
instances: [{ id: "4", label: "Corp" }],
|
||||
submit,
|
||||
});
|
||||
fireEvent.click(screen.getByText("Login with Corp"));
|
||||
fireEvent.change(screen.getByLabelText(locales.username), {
|
||||
target: { value: " bob " },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(locales.password), {
|
||||
target: { value: "hunter2" },
|
||||
});
|
||||
fireEvent.click(screen.getByText(locales.signIn));
|
||||
await waitFor(() =>
|
||||
expect(submit).toHaveBeenCalledWith(
|
||||
{ username: "bob", password: "hunter2" },
|
||||
"4",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not submit an empty form", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const submit = vi.fn(async () => {});
|
||||
rendered.renderLoginMethod("ldap", {
|
||||
instances: [{ id: "4", label: "Corp" }],
|
||||
submit,
|
||||
});
|
||||
fireEvent.click(screen.getByText("Login with Corp"));
|
||||
fireEvent.click(screen.getByText(locales.signIn));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists directories without their bind password", async () => {
|
||||
const get = vi.fn(async () => ({
|
||||
data: {
|
||||
providers: [
|
||||
{
|
||||
id: 4,
|
||||
name: "Corp LDAP",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: { host: "ldap.example" },
|
||||
hasBindPassword: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get } as never,
|
||||
});
|
||||
rendered.renderSettingsComponent("providers");
|
||||
expect(await screen.findByText("Corp LDAP")).toBeTruthy();
|
||||
expect(get).toHaveBeenCalledWith("/providers");
|
||||
});
|
||||
|
||||
it("removes everything on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.loginMethods()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user