mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
-- sso_providers moves to the sso plugin's adopted table at activation, and
|
||||||
|
-- its LDAP rows are copied into the ldap plugin by a boot migration after
|
||||||
|
-- this runs. Dropping it here would lose the data first.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -365,6 +365,13 @@
|
|||||||
"when": 1790285910582,
|
"when": 1790285910582,
|
||||||
"tag": "0051_icy_hercules",
|
"tag": "0051_icy_hercules",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 52,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790287990810,
|
||||||
|
"tag": "0052_illegal_gladiator",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- sso_providers moves to the sso plugin's adopted table at activation, and
|
||||||
|
-- its LDAP rows are copied into the ldap plugin by a boot migration after
|
||||||
|
-- this runs. Dropping it here would lose the data first.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -372,6 +372,13 @@
|
|||||||
"when": 1790285909968,
|
"when": 1790285909968,
|
||||||
"tag": "0052_motionless_krista_starr",
|
"tag": "0052_motionless_krista_starr",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790287990260,
|
||||||
|
"tag": "0053_keen_thanos",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- sso_providers moves to the sso plugin's adopted table at activation, and
|
||||||
|
-- its LDAP rows are copied into the ldap plugin by a boot migration after
|
||||||
|
-- this runs. Dropping it here would lose the data first.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -344,6 +344,13 @@
|
|||||||
"when": 1790285909308,
|
"when": 1790285909308,
|
||||||
"tag": "0048_cold_miek",
|
"tag": "0048_cold_miek",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 49,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790287989611,
|
||||||
|
"tag": "0049_glamorous_hemingway",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,6 @@
|
|||||||
"express",
|
"express",
|
||||||
"guacamole-lite",
|
"guacamole-lite",
|
||||||
"guacamole-common-js",
|
"guacamole-common-js",
|
||||||
"jose",
|
|
||||||
"js-yaml",
|
"js-yaml",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"jszip",
|
"jszip",
|
||||||
|
|||||||
Generated
+31
-3
@@ -16,7 +16,6 @@
|
|||||||
"@anthropic-ai/sdk": "^0.120.0",
|
"@anthropic-ai/sdk": "^0.120.0",
|
||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/ldapjs": "^3.0.6",
|
|
||||||
"@types/semver": "^7.8.0",
|
"@types/semver": "^7.8.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -30,11 +29,9 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"guacamole-lite": "^1.2.0",
|
"guacamole-lite": "^1.2.0",
|
||||||
"jose": "^6.2.12",
|
|
||||||
"js-yaml": "^5.2.3",
|
"js-yaml": "^5.2.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jszip": "^3.10.2",
|
"jszip": "^3.10.2",
|
||||||
"ldapjs": "^3.0.7",
|
|
||||||
"motion": "^13.1.1",
|
"motion": "^13.1.1",
|
||||||
"multer": "^2.3.0",
|
"multer": "^2.3.0",
|
||||||
"mysql2": "^3.23.4",
|
"mysql2": "^3.23.4",
|
||||||
@@ -6998,6 +6995,10 @@
|
|||||||
"resolved": "plugins/host-metrics",
|
"resolved": "plugins/host-metrics",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@termix-plugin/ldap": {
|
||||||
|
"resolved": "plugins/ldap",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@termix-plugin/network-topology": {
|
"node_modules/@termix-plugin/network-topology": {
|
||||||
"resolved": "plugins/network-topology",
|
"resolved": "plugins/network-topology",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -7034,6 +7035,10 @@
|
|||||||
"resolved": "plugins/ssh-terminal",
|
"resolved": "plugins/ssh-terminal",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@termix-plugin/sso": {
|
||||||
|
"resolved": "plugins/sso",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@termix-plugin/tailscale": {
|
"node_modules/@termix-plugin/tailscale": {
|
||||||
"resolved": "plugins/tailscale",
|
"resolved": "plugins/tailscale",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -7381,6 +7386,7 @@
|
|||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz",
|
||||||
"integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==",
|
"integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -18604,6 +18610,17 @@
|
|||||||
"@termix/plugin-sdk": "*"
|
"@termix/plugin-sdk": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins/ldap": {
|
||||||
|
"name": "@termix-plugin/ldap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapjs": "^3.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@termix/plugin-sdk": "*",
|
||||||
|
"@types/ldapjs": "^3.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"plugins/network-topology": {
|
"plugins/network-topology": {
|
||||||
"name": "@termix-plugin/network-topology",
|
"name": "@termix-plugin/network-topology",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -18680,6 +18697,17 @@
|
|||||||
"@termix/plugin-sdk": "*"
|
"@termix/plugin-sdk": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins/sso": {
|
||||||
|
"name": "@termix-plugin/sso",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.2.12",
|
||||||
|
"undici": "^8.10.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@termix/plugin-sdk": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"plugins/tailscale": {
|
"plugins/tailscale": {
|
||||||
"name": "@termix-plugin/tailscale",
|
"name": "@termix-plugin/tailscale",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
"@anthropic-ai/sdk": "^0.120.0",
|
"@anthropic-ai/sdk": "^0.120.0",
|
||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/ldapjs": "^3.0.6",
|
|
||||||
"@types/semver": "^7.8.0",
|
"@types/semver": "^7.8.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -72,11 +71,9 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"guacamole-lite": "^1.2.0",
|
"guacamole-lite": "^1.2.0",
|
||||||
"jose": "^6.2.12",
|
|
||||||
"js-yaml": "^5.2.3",
|
"js-yaml": "^5.2.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jszip": "^3.10.2",
|
"jszip": "^3.10.2",
|
||||||
"ldapjs": "^3.0.7",
|
|
||||||
"motion": "^13.1.1",
|
"motion": "^13.1.1",
|
||||||
"multer": "^2.3.0",
|
"multer": "^2.3.0",
|
||||||
"mysql2": "^3.23.4",
|
"mysql2": "^3.23.4",
|
||||||
|
|||||||
@@ -999,11 +999,11 @@ auto-session and trusted devices, plus the base SSH auth types password, key,
|
|||||||
stored credential, agent and none. Every other login method, second factor and
|
stored credential, agent and none. Every other login method, second factor and
|
||||||
SSH auth method is a plugin through `ctx.auth`.
|
SSH auth method is a plugin through `ctx.auth`.
|
||||||
|
|
||||||
In 2.9.0, OIDC (and GitHub, Google), LDAP, OPKSSH, Step-CA, Vault, Tailscale
|
In 2.9.0, OPKSSH, Step-CA, Vault, Tailscale and Warpgate still live in core.
|
||||||
and Warpgate still live in core. They register through the same interfaces
|
They register through the same interfaces from
|
||||||
from `src/backend/auth/legacy-providers.ts` and
|
`src/backend/auth/legacy-providers.ts` and `src/ui/auth/legacy-auth-ui.tsx`
|
||||||
`src/ui/auth/legacy-auth-ui.tsx` with `pluginId: "core"`. Phase C moves each
|
with `pluginId: "core"`. Phase C moves each one into its plugin by moving its
|
||||||
one into its plugin by moving its block out of those two files; D1 deletes them.
|
block out of those two files; D1 deletes them.
|
||||||
Nothing else in core branches on those type names.
|
Nothing else in core branches on those type names.
|
||||||
|
|
||||||
**C1** moved TOTP and passkeys out:
|
**C1** moved TOTP and passkeys out:
|
||||||
@@ -1043,6 +1043,52 @@ Nothing else in core branches on those type names.
|
|||||||
`AuthManager.unlockWithSystemKey` (was `authenticateWebAuthnUser`) opens
|
`AuthManager.unlockWithSystemKey` (was `authenticateWebAuthnUser`) opens
|
||||||
the data key for any identity that carries no password.
|
the data key for any identity that carries no password.
|
||||||
|
|
||||||
|
**C2** moved external logins out:
|
||||||
|
|
||||||
|
- `sso` registers the `oidc` login method: OIDC, GitHub and Google, one
|
||||||
|
button per enabled provider (the method's instances), plus the provider
|
||||||
|
configured through the `OIDC_*` environment variables as instance `0`. It
|
||||||
|
adopts `sso_providers` as `p_sso_providers`, ignoring any `ldap` rows left
|
||||||
|
in it. Its public routes are `/plugin-api/sso/start` (sends the browser to
|
||||||
|
the provider), `/callback` (GET and POST), `/backchannel-logout` and
|
||||||
|
`/config`; provider management is `/plugin-api/sso/providers`, gated on
|
||||||
|
`sso.manage`, and renders as the custom `providers` field on the plugin's
|
||||||
|
admin settings page. Login state (nonce, PKCE verifier, return address)
|
||||||
|
lives in `ctx.kv` for ten minutes.
|
||||||
|
- The redirect URI is `<base>/plugin-api/sso/callback`. Providers that
|
||||||
|
existed before 2.9 keep sending `<base>/users/oidc/callback`, the URI their
|
||||||
|
identity provider was registered with, because the sso plugin's second
|
||||||
|
migration sets `legacy_callback` on every adopted row; the env provider
|
||||||
|
always does. Core answers that old URL with a 308 to the plugin's callback,
|
||||||
|
and the token exchange sends the same redirect URI the authorize request
|
||||||
|
did, so strict providers accept it. The admin page shows each provider's
|
||||||
|
URI and switches it to the new one once the admin has registered that.
|
||||||
|
`/users/oidc/backchannel-logout` is a 308 to the plugin as well.
|
||||||
|
- `ldap` registers the `ldap` form method, one form per directory, in its own
|
||||||
|
`p_ldap_providers`. `utils/crypto-migration/ldap-provider-migration.ts`
|
||||||
|
copies the 2.8 LDAP rows out of `sso_providers` (adopted or not) with their
|
||||||
|
ids and deletes them there. It runs from the loader whenever a plugin's
|
||||||
|
migrations applied something (`plugin-data-moves.ts`), so the rows are in
|
||||||
|
place before the ldap plugin first activates, at boot or when it is enabled
|
||||||
|
later. LDAP identities are stored under provider `ldap:<id>` so their ids
|
||||||
|
can never clash with SSO ones; `external-identity-migration.ts` writes them
|
||||||
|
that way and the LDAP migration renames any bare ones.
|
||||||
|
- Both return 2.8's `users.oidc_identifier` value in `legacy`, so pre-2.9
|
||||||
|
accounts are found and new ones keep the column filled. The sso plugin
|
||||||
|
passes its group role map as `roles` and the id token's `sub`/`sid` as
|
||||||
|
`logoutClaims`; `/backchannel-logout` ends those sessions with
|
||||||
|
`ctx.auth.revokeSessions`. LDAP counts failed binds with
|
||||||
|
`ctx.auth.loginRateLimit`, the limiter password login uses.
|
||||||
|
- Core keeps provisioning (the allowed-users matcher is
|
||||||
|
`auth/allowed-users.ts`), `oidc_auto_provision`, silent sign-in (it starts
|
||||||
|
the first redirect method), account linking and the trusted proxy rule:
|
||||||
|
every `external` method is refused while trusted proxy login is on, and
|
||||||
|
`/users/proxy-login` refuses while any external method lists an instance.
|
||||||
|
The 2.8 client routes `/users/oidc/authorize`, `/users/oidc-config` (307 to
|
||||||
|
`/plugin-api/sso/config`), `/users/sso-providers` and `/users/ldap/login`
|
||||||
|
live in `database/routes/auth-compat-routes.ts` and only forward to the
|
||||||
|
registered methods, so with the plugins off they answer 404.
|
||||||
|
|
||||||
#### One SSH connect pipeline
|
#### One SSH connect pipeline
|
||||||
|
|
||||||
Every SSH connection, core's and plugins', goes through
|
Every SSH connection, core's and plugins', goes through
|
||||||
@@ -1171,10 +1217,25 @@ or an external identity. Core then:
|
|||||||
Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
|
Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
|
||||||
`GET|POST :methodId/callback`, `POST :methodId/verify`,
|
`GET|POST :methodId/callback`, `POST :methodId/verify`,
|
||||||
`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`.
|
`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`.
|
||||||
A redirect method's provider must send the browser back to
|
A redirect method's provider sends the browser back to
|
||||||
`/users/auth/<methodId>/callback`. The older routes (`/users/login`,
|
`/users/auth/<methodId>/callback`, or to a public route of the plugin's own
|
||||||
|
that calls `ctx.auth.completeRedirectLogin(methodId, req, res)`, which runs
|
||||||
|
the same callback and pipeline (**C2**, for a URL that has to stay stable in
|
||||||
|
the provider's configuration). The older routes (`/users/login`,
|
||||||
`/users/oidc/*`, `/users/ldap/login`) are thin wrappers over the same
|
`/users/oidc/*`, `/users/ldap/login`) are thin wrappers over the same
|
||||||
pipeline. `/users/totp/verify-login` stays for 2.8 clients: it answers with
|
pipeline.
|
||||||
|
|
||||||
|
**Identity extras (C2).** An external identity may also carry `roles`
|
||||||
|
(`{ desired, managed }`: core adds the desired roles and removes managed ones
|
||||||
|
the user no longer maps to), `logoutClaims` (`providerId`, `sub`, `sid`,
|
||||||
|
stored on the session for `ctx.auth.revokeSessions`), `legacy` (the 2.8
|
||||||
|
`oidc_identifier` and `sso_provider_id`, only for a plugin that took over a
|
||||||
|
2.8 method) and `rateLimitKey` (cleared on success; keys are kept apart per
|
||||||
|
plugin). Core copies only these documented fields, so a plugin cannot set
|
||||||
|
core-only ones. `ctx.auth.countLinkedUsers(provider)` answers how many users
|
||||||
|
sign in through a provider, for refusing to delete one still in use.
|
||||||
|
`ctx.http.baseUrl(req)` is the install's public URL (origin plus base path,
|
||||||
|
honouring `OIDC_FORCE_HTTPS`) for building callback URLs. `/users/totp/verify-login` stays for 2.8 clients: it answers with
|
||||||
the factor named in `factor`, or the user's first required factor, and core
|
the factor named in `factor`, or the user's first required factor, and core
|
||||||
never names one itself.
|
never names one itself.
|
||||||
|
|
||||||
@@ -1562,6 +1623,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
|||||||
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
|
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
|
||||||
| `ctx.sync.registerEntity` | none | **A3** |
|
| `ctx.sync.registerEntity` | none | **A3** |
|
||||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||||
|
| `ctx.http.baseUrl(req)` | none | **C2** |
|
||||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||||
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
|
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
|
||||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
|
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
|
||||||
@@ -1570,6 +1632,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
|||||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||||
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
|
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
|
||||||
| `ctx.auth.*` | `auth:provide` | **A8** |
|
| `ctx.auth.*` | `auth:provide` | **A8** |
|
||||||
|
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
|
||||||
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
||||||
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
||||||
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||||
@@ -2361,8 +2424,8 @@ Then, with the app running:
|
|||||||
|
|
||||||
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
||||||
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
|
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
|
||||||
(B17), ai (B18), homepage (B19), totp and webauthn (C1), which import
|
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
|
||||||
nothing from core. The others still reach core by relative
|
which import nothing from core. The others still reach core by relative
|
||||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||||
|
|||||||
@@ -465,6 +465,27 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
|||||||
and `totp_backup_codes` stay in the database (dropped from `schema.ts`, the
|
and `totp_backup_codes` stay in the database (dropped from `schema.ts`, the
|
||||||
drizzle drop is a no-op) until 3.0.0 removes them, after the boot
|
drizzle drop is a no-op) until 3.0.0 removes them, after the boot
|
||||||
migration has run everywhere. Owner: 3.0.0.
|
migration has run everywhere. Owner: 3.0.0.
|
||||||
|
- **C2 (sso/ldap):** `users.is_oidc`, `oidc_identifier`, `sso_provider_id`
|
||||||
|
and the 2.8 per-user OIDC columns (`client_id`, `issuer_url`, ...), and
|
||||||
|
`sessions.sso_provider_id`, `oidc_sub`, `oidc_sid`, stay in core. Core
|
||||||
|
still reads `is_oidc`/`oidc_identifier` for account linking, `/users/me` and
|
||||||
|
the `$oidc.preferred_username` placeholder, and still writes them from an
|
||||||
|
identity's `legacy` field so a downgrade works. Rename or drop them once
|
||||||
|
nothing needs the 2.8 shape. Owner: 3.0.0.
|
||||||
|
- **C2 (sso/ldap):** `database/routes/auth-compat-routes.ts` keeps the 2.8
|
||||||
|
URLs (`/users/oidc/*`, `/users/oidc-config`, `/users/sso-providers`,
|
||||||
|
`/users/ldap/login`) and so names the `oidc`/`ldap` method ids and the sso
|
||||||
|
plugin's `/plugin-api/sso/` paths. Keep until identity providers have moved
|
||||||
|
to the new redirect URI and Termix-Mobile uses `/users/auth/*`. Owner:
|
||||||
|
3.0.0.
|
||||||
|
- **C2 (sso):** the `oidc_auto_provision` and `oidc_silent_login_default`
|
||||||
|
settings, their routes and admin toggles are generic (every external
|
||||||
|
method, every redirect method) but still carry OIDC in their names and
|
||||||
|
i18n. Rename with a settings migration if wanted. Owner: D0.
|
||||||
|
- **Unrelated, found in C2:** `node scripts/check-shell-plugin-ids.cjs`
|
||||||
|
fails on `src/ui/lib/host-to-ssh-host.ts` naming `"web-endpoint"`
|
||||||
|
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
|
||||||
|
C2. Owner: D0.
|
||||||
|
|
||||||
## Manual checks after 2.9.0
|
## Manual checks after 2.9.0
|
||||||
|
|
||||||
@@ -539,3 +560,15 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
|||||||
plugin and a TOTP user's login is refused until an admin resets their
|
plugin and a TOTP user's login is refused until an admin resets their
|
||||||
factors; register a passkey, sign in with it with and without a PIN (no
|
factors; register a passkey, sign in with it with and without a PIN (no
|
||||||
PIN still asks for TOTP), delete it; existing 2.8 passkeys still sign in.
|
PIN still asks for TOTP), delete it; existing 2.8 passkeys still sign in.
|
||||||
|
- SSO and LDAP: on a 2.8 database every SSO provider and LDAP directory is
|
||||||
|
there, in Settings > Plugins > Single sign-on and > LDAP; an existing
|
||||||
|
provider still signs in through its identity provider without changing
|
||||||
|
anything there (its redirect URI shows `/users/oidc/callback`); add the
|
||||||
|
new redirect URI at the provider, switch it over and sign in again; a new
|
||||||
|
provider, GitHub and Google; an existing SSO user and an existing LDAP
|
||||||
|
user land in their old accounts; the env-configured provider; admin group
|
||||||
|
and role map; back-channel logout from Keycloak ends the session; the
|
||||||
|
desktop app's system browser login and Termix-Mobile's login; silent
|
||||||
|
sign-in; a second factor after an external login with the admin setting
|
||||||
|
on and off; disable each plugin and its buttons go away and its URLs stop
|
||||||
|
signing anyone in, then enable it again.
|
||||||
@@ -313,6 +313,12 @@ export interface PluginHttp {
|
|||||||
* on deactivate.
|
* on deactivate.
|
||||||
*/
|
*/
|
||||||
router: <T = unknown>(options?: PluginRouterOptions) => T;
|
router: <T = unknown>(options?: PluginRouterOptions) => T;
|
||||||
|
/**
|
||||||
|
* The public base URL a request came in on: origin plus the install's base
|
||||||
|
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
|
||||||
|
* BASE_PATH and OIDC_FORCE_HTTPS. For building absolute callback URLs.
|
||||||
|
*/
|
||||||
|
baseUrl: (req: unknown) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1102,6 +1108,33 @@ export type PluginVerifiedIdentity =
|
|||||||
/** Redirect methods: where the browser goes back to. */
|
/** Redirect methods: where the browser goes back to. */
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
rememberMe?: boolean;
|
rememberMe?: boolean;
|
||||||
|
/**
|
||||||
|
* Provider groups mapped to Termix roles. Core adds the desired roles
|
||||||
|
* and removes managed roles the user no longer maps to, on every login.
|
||||||
|
* Roles outside `managed` are never touched.
|
||||||
|
*/
|
||||||
|
roles?: { desired: string[]; managed: string[] };
|
||||||
|
/**
|
||||||
|
* Stored on the session so `ctx.auth.revokeSessions` can find it later,
|
||||||
|
* for an identity provider's back-channel logout.
|
||||||
|
*/
|
||||||
|
logoutClaims?: {
|
||||||
|
providerId?: number | null;
|
||||||
|
sub?: string | null;
|
||||||
|
sid?: string | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The values a 2.8 install kept on the user row (`oidc_identifier`,
|
||||||
|
* `sso_provider_id`). Only for a plugin that took over a 2.8 login
|
||||||
|
* method: core finds pre-2.9 accounts by `identifier` and keeps writing
|
||||||
|
* both for new users so a downgrade still works.
|
||||||
|
*/
|
||||||
|
legacy?: { identifier: string; providerRowId?: number | null };
|
||||||
|
/**
|
||||||
|
* The key this attempt was counted under with
|
||||||
|
* `ctx.auth.loginRateLimit.recordFailure`. Cleared on success.
|
||||||
|
*/
|
||||||
|
rateLimitKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Request shape a login method sees. Structural, like PluginRequestLike. */
|
/** Request shape a login method sees. Structural, like PluginRequestLike. */
|
||||||
@@ -1118,6 +1151,11 @@ export interface PluginLoginInstance {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* Provider type shown to 2.8 clients by the old `GET /users/sso-providers`
|
||||||
|
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginLoginMethod {
|
export interface PluginLoginMethod {
|
||||||
@@ -1184,6 +1222,46 @@ export interface PluginAuth {
|
|||||||
*/
|
*/
|
||||||
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
|
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
|
||||||
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
|
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Finishes a redirect login from one of this plugin's own public routes:
|
||||||
|
* runs the registered method's `callback`, then core finds or provisions
|
||||||
|
* the user, runs second factors and redirects the browser back with a
|
||||||
|
* session, exactly like `/users/auth/<methodId>/callback`. The method must
|
||||||
|
* be one this plugin registered.
|
||||||
|
*/
|
||||||
|
completeRedirectLogin: (
|
||||||
|
methodId: string,
|
||||||
|
req: unknown,
|
||||||
|
res: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Revokes sessions whose login carried matching `logoutClaims`, for a
|
||||||
|
* back-channel logout. Needs `sub` or `sid`; `providerId` narrows it.
|
||||||
|
* Returns how many sessions ended.
|
||||||
|
*/
|
||||||
|
revokeSessions: (match: {
|
||||||
|
providerId?: number | null;
|
||||||
|
sub?: string | null;
|
||||||
|
sid?: string | null;
|
||||||
|
}) => Promise<number>;
|
||||||
|
/**
|
||||||
|
* Core's login rate limiter, the one password login uses. Keys are kept
|
||||||
|
* apart per plugin. Put the key in the identity's `rateLimitKey` so a
|
||||||
|
* successful login clears it.
|
||||||
|
*/
|
||||||
|
loginRateLimit: {
|
||||||
|
isLocked: (
|
||||||
|
ip: string,
|
||||||
|
key: string,
|
||||||
|
) => Promise<{ locked: boolean; remainingTime?: number }>;
|
||||||
|
recordFailure: (ip: string, key: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* How many users are linked to an external identity from `provider` (the
|
||||||
|
* `provider` of the identities this plugin returns). For refusing to
|
||||||
|
* delete a provider people still sign in with.
|
||||||
|
*/
|
||||||
|
countLinkedUsers: (provider: string) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginOpenIsolatedWindowRequest {
|
export interface PluginOpenIsolatedWindowRequest {
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import type {
|
|||||||
PluginWebSocketOptions,
|
PluginWebSocketOptions,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
|
|
||||||
|
/** The bits of an express Response the fake completeRedirectLogin uses. */
|
||||||
|
interface FakeResponse {
|
||||||
|
status: (code: number) => FakeResponse;
|
||||||
|
json: (body: unknown) => unknown;
|
||||||
|
redirect: (url: string) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** A ctx.ws registration as the test doubles record it. */
|
/** A ctx.ws registration as the test doubles record it. */
|
||||||
export interface FakeWsRoute {
|
export interface FakeWsRoute {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -37,6 +44,8 @@ export interface FakeWsRoute {
|
|||||||
}
|
}
|
||||||
import type {
|
import type {
|
||||||
PluginLoginMethod,
|
PluginLoginMethod,
|
||||||
|
PluginLoginRequest,
|
||||||
|
PluginVerifiedIdentity,
|
||||||
PluginSecondFactor,
|
PluginSecondFactor,
|
||||||
PluginSshAuthProvider,
|
PluginSshAuthProvider,
|
||||||
PluginSshConnectOptions,
|
PluginSshConnectOptions,
|
||||||
@@ -123,6 +132,12 @@ export interface FakeContextOptions {
|
|||||||
* proxy login on, password login off), with this message and a 409.
|
* proxy login on, password login off), with this message and a 409.
|
||||||
*/
|
*/
|
||||||
refuseEnrollment?: string;
|
refuseEnrollment?: string;
|
||||||
|
/** What ctx.http.baseUrl answers. Defaults to "https://termix.test". */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
||||||
|
linkedUsers?: Record<string, number>;
|
||||||
|
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
||||||
|
loginAttemptLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeAuthRegistrations {
|
export interface FakeAuthRegistrations {
|
||||||
@@ -136,6 +151,19 @@ export interface FakeAuthRegistrations {
|
|||||||
string,
|
string,
|
||||||
(userId: string, reference: string) => Promise<string>
|
(userId: string, reference: string) => Promise<string>
|
||||||
>;
|
>;
|
||||||
|
/** Identities handed to ctx.auth.completeRedirectLogin, in order. */
|
||||||
|
completedLogins: Array<{
|
||||||
|
methodId: string;
|
||||||
|
identity: PluginVerifiedIdentity;
|
||||||
|
}>;
|
||||||
|
/** Every ctx.auth.revokeSessions call, in order. */
|
||||||
|
revokedSessions: Array<{
|
||||||
|
providerId?: number | null;
|
||||||
|
sub?: string | null;
|
||||||
|
sid?: string | null;
|
||||||
|
}>;
|
||||||
|
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
|
||||||
|
loginFailures: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakePluginContext {
|
export interface FakePluginContext {
|
||||||
@@ -347,6 +375,9 @@ export function createFakeContext(
|
|||||||
secondFactors: [],
|
secondFactors: [],
|
||||||
enrollments: new Set(),
|
enrollments: new Set(),
|
||||||
secretResolvers: new Map(),
|
secretResolvers: new Map(),
|
||||||
|
completedLogins: [],
|
||||||
|
revokedSessions: [],
|
||||||
|
loginFailures: new Map(),
|
||||||
};
|
};
|
||||||
const sshClient = options.sshClient ?? {};
|
const sshClient = options.sshClient ?? {};
|
||||||
let actor = options.actor;
|
let actor = options.actor;
|
||||||
@@ -531,6 +562,7 @@ export function createFakeContext(
|
|||||||
httpRouters.push(routerOptions);
|
httpRouters.push(routerOptions);
|
||||||
return (options.router ? options.router() : undefined) as never;
|
return (options.router ? options.router() : undefined) as never;
|
||||||
},
|
},
|
||||||
|
baseUrl: () => options.baseUrl ?? "https://termix.test",
|
||||||
},
|
},
|
||||||
|
|
||||||
ws: {
|
ws: {
|
||||||
@@ -801,6 +833,56 @@ export function createFakeContext(
|
|||||||
removeEnrollment: async (userId, factorId) => {
|
removeEnrollment: async (userId, factorId) => {
|
||||||
auth.enrollments.delete(`${userId}:${factorId}`);
|
auth.enrollments.delete(`${userId}:${factorId}`);
|
||||||
},
|
},
|
||||||
|
// Stands in for core's pipeline: answers with the identity as JSON, or
|
||||||
|
// redirects back with the error the way core does.
|
||||||
|
completeRedirectLogin: async (methodId, req, res) => {
|
||||||
|
const response = res as FakeResponse;
|
||||||
|
const method = auth.loginMethods.find(
|
||||||
|
(candidate) => candidate.id === methodId,
|
||||||
|
);
|
||||||
|
if (!method?.callback) {
|
||||||
|
throw new Error(`No registered redirect method "${methodId}"`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const identity = await method.callback(req as PluginLoginRequest);
|
||||||
|
auth.completedLogins.push({ methodId, identity });
|
||||||
|
response.status(200).json({ completed: methodId, identity });
|
||||||
|
} catch (error) {
|
||||||
|
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||||
|
const message = (error as Error).message;
|
||||||
|
if (returnTo) {
|
||||||
|
const url = new URL(returnTo);
|
||||||
|
url.searchParams.set(
|
||||||
|
"error",
|
||||||
|
(error as { code?: string }).code ?? message,
|
||||||
|
);
|
||||||
|
response.redirect(url.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.status((error as { status?: number }).status ?? 500)
|
||||||
|
.json({ error: message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
revokeSessions: async (match) => {
|
||||||
|
if (!match.sub && !match.sid) return 0;
|
||||||
|
auth.revokedSessions.push(match);
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
loginRateLimit: {
|
||||||
|
isLocked: async (ip, key) => {
|
||||||
|
const failures = auth.loginFailures.get(`${ip}|${key}`) ?? 0;
|
||||||
|
return failures >= (options.loginAttemptLimit ?? 5)
|
||||||
|
? { locked: true, remainingTime: 60 }
|
||||||
|
: { locked: false };
|
||||||
|
},
|
||||||
|
recordFailure: async (ip, key) => {
|
||||||
|
const id = `${ip}|${key}`;
|
||||||
|
auth.loginFailures.set(id, (auth.loginFailures.get(id) ?? 0) + 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
countLinkedUsers: async (provider) =>
|
||||||
|
options.linkedUsers?.[provider] ?? 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
@@ -943,6 +1025,12 @@ export interface MockContextOptions {
|
|||||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||||
/** See FakeContextOptions. */
|
/** See FakeContextOptions. */
|
||||||
refuseEnrollment?: string;
|
refuseEnrollment?: string;
|
||||||
|
/** See FakeContextOptions. */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** See FakeContextOptions. */
|
||||||
|
linkedUsers?: Record<string, number>;
|
||||||
|
/** See FakeContextOptions. */
|
||||||
|
loginAttemptLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockPluginContext extends FakePluginContext {
|
export interface MockPluginContext extends FakePluginContext {
|
||||||
@@ -986,6 +1074,9 @@ export function createMockCtx(
|
|||||||
notificationChannels: options.notificationChannels,
|
notificationChannels: options.notificationChannels,
|
||||||
fetch: options.fetch,
|
fetch: options.fetch,
|
||||||
refuseEnrollment: options.refuseEnrollment,
|
refuseEnrollment: options.refuseEnrollment,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
linkedUsers: options.linkedUsers,
|
||||||
|
loginAttemptLimit: options.loginAttemptLimit,
|
||||||
manifest: {
|
manifest: {
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
...options.manifest,
|
...options.manifest,
|
||||||
@@ -1078,6 +1169,7 @@ export function createMockCtx(
|
|||||||
require("network:serve");
|
require("network:serve");
|
||||||
return ctx.http.router(routerOptions);
|
return ctx.http.router(routerOptions);
|
||||||
},
|
},
|
||||||
|
baseUrl: (req) => ctx.http.baseUrl(req),
|
||||||
},
|
},
|
||||||
|
|
||||||
ws: {
|
ws: {
|
||||||
@@ -1233,6 +1325,28 @@ export function createMockCtx(
|
|||||||
require("auth:provide");
|
require("auth:provide");
|
||||||
return ctx.auth.removeEnrollment(userId, factorId);
|
return ctx.auth.removeEnrollment(userId, factorId);
|
||||||
},
|
},
|
||||||
|
completeRedirectLogin: async (methodId, req, res) => {
|
||||||
|
require("auth:provide");
|
||||||
|
return ctx.auth.completeRedirectLogin(methodId, req, res);
|
||||||
|
},
|
||||||
|
revokeSessions: async (match) => {
|
||||||
|
require("auth:provide");
|
||||||
|
return ctx.auth.revokeSessions(match);
|
||||||
|
},
|
||||||
|
loginRateLimit: {
|
||||||
|
isLocked: async (ip, key) => {
|
||||||
|
require("auth:provide");
|
||||||
|
return ctx.auth.loginRateLimit.isLocked(ip, key);
|
||||||
|
},
|
||||||
|
recordFailure: async (ip, key) => {
|
||||||
|
require("auth:provide");
|
||||||
|
return ctx.auth.loginRateLimit.recordFailure(ip, key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
countLinkedUsers: async (provider) => {
|
||||||
|
require("auth:provide");
|
||||||
|
return ctx.auth.countLinkedUsers(provider);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
|
|||||||
@@ -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,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Teken aan met {{name}}",
|
||||||
|
"loading": "Laai tans...",
|
||||||
|
"requiredField": "Hierdie veld is verpligtend",
|
||||||
|
"username": "LDAP-gebruikersnaam",
|
||||||
|
"password": "LDAP-wagwoord",
|
||||||
|
"signIn": "Aanmeld",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Bekyk dokumente",
|
||||||
|
"name": "Vertoonnaam",
|
||||||
|
"enabled": "Geaktiveer",
|
||||||
|
"saving": "Stoor...",
|
||||||
|
"saved": "Gestoor",
|
||||||
|
"saveFailed": "Kon nie stoor nie",
|
||||||
|
"deleted": "Verwyder",
|
||||||
|
"deleteFailed": "Kon nie verwyder nie",
|
||||||
|
"cancel": "Kanselleer"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-gasheer",
|
||||||
|
"port": "Hawe",
|
||||||
|
"useTls": "Gebruik TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind Wagwoord",
|
||||||
|
"userSearchBase": "Gebruikersoekbasis",
|
||||||
|
"userSearchFilter": "Gebruikersoekfilter",
|
||||||
|
"usernameAttr": "Gebruikersnaam-kenmerk",
|
||||||
|
"displayNameAttr": "Vertoonnaam-kenmerk",
|
||||||
|
"groupSearchBase": "Groepsoekbasis",
|
||||||
|
"adminGroup": "Admingroep",
|
||||||
|
"allowedUsers": "Toegelate Gebruikers"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "تسجيل الدخول باستخدام {{name}}",
|
||||||
|
"loading": "جارٍ التحميل...",
|
||||||
|
"requiredField": "هذا الحقل مطلوب",
|
||||||
|
"username": "اسم مستخدم LDAP",
|
||||||
|
"password": "كلمة مرور LDAP",
|
||||||
|
"signIn": "تسجيل الدخول",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "عرض المستندات",
|
||||||
|
"name": "الاسم المعروض",
|
||||||
|
"enabled": "مُمكّن",
|
||||||
|
"saving": "جارٍ الحفظ...",
|
||||||
|
"saved": "محفوظ",
|
||||||
|
"saveFailed": "فشل في الحفظ",
|
||||||
|
"deleted": "محذوف",
|
||||||
|
"deleteFailed": "فشل في الحذف",
|
||||||
|
"cancel": "إلغاء"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "مضيف LDAP",
|
||||||
|
"port": "المنفذ",
|
||||||
|
"useTls": "استخدام TLS (LDAPS)",
|
||||||
|
"bindDn": "اسم مميز للربط",
|
||||||
|
"bindPassword": "كلمة مرور الارتباط",
|
||||||
|
"userSearchBase": "قاعدة بحث المستخدمين",
|
||||||
|
"userSearchFilter": "مرشح بحث المستخدمين",
|
||||||
|
"usernameAttr": "سمة اسم المستخدم",
|
||||||
|
"displayNameAttr": "سمة اسم العرض",
|
||||||
|
"groupSearchBase": "قاعدة بحث المجموعات",
|
||||||
|
"adminGroup": "مجموعة المديرين",
|
||||||
|
"allowedUsers": "المستخدمون المسموح بهم"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Влезте с {{name}}",
|
||||||
|
"loading": "Зареждане...",
|
||||||
|
"requiredField": "Това поле е задължително",
|
||||||
|
"username": "Потребителско име на LDAP",
|
||||||
|
"password": "LDAP парола",
|
||||||
|
"signIn": "Вход",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Преглед на документи",
|
||||||
|
"name": "Показвано име",
|
||||||
|
"enabled": "Активирано",
|
||||||
|
"saving": "Запазване...",
|
||||||
|
"saved": "Запазено",
|
||||||
|
"saveFailed": "Запазването не бе успешно",
|
||||||
|
"deleted": "Изтрито",
|
||||||
|
"deleteFailed": "Изтриването не бе успешно",
|
||||||
|
"cancel": "Отказ"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP хост",
|
||||||
|
"port": "Порт",
|
||||||
|
"useTls": "Използвайте TLS (LDAPS)",
|
||||||
|
"bindDn": "Обвързване DN",
|
||||||
|
"bindPassword": "Свързване на парола",
|
||||||
|
"userSearchBase": "База за търсене на потребители",
|
||||||
|
"userSearchFilter": "Филтър за търсене на потребители",
|
||||||
|
"usernameAttr": "Атрибут на потребителското име",
|
||||||
|
"displayNameAttr": "Атрибут за показвано име",
|
||||||
|
"groupSearchBase": "База за групово търсене",
|
||||||
|
"adminGroup": "Административна група",
|
||||||
|
"allowedUsers": "Разрешени потребители"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}} দিয়ে লগইন করুন",
|
||||||
|
"loading": "লোড হচ্ছে...",
|
||||||
|
"requiredField": "এই ক্ষেত্রটি আবশ্যক",
|
||||||
|
"username": "LDAP ব্যবহারকারীর নাম",
|
||||||
|
"password": "এলডিএপি পাসওয়ার্ড",
|
||||||
|
"signIn": "সাইন ইন করুন",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "নথি দেখুন",
|
||||||
|
"name": "প্রদর্শনের নাম",
|
||||||
|
"enabled": "সক্ষম করা হয়েছে",
|
||||||
|
"saving": "সঞ্চয়...",
|
||||||
|
"saved": "সংরক্ষিত",
|
||||||
|
"saveFailed": "সংরক্ষণ করতে ব্যর্থ হয়েছে",
|
||||||
|
"deleted": "মুছে ফেলা হয়েছে",
|
||||||
|
"deleteFailed": "মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||||
|
"cancel": "বাতিল করুন"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "এলডিএপি হোস্ট",
|
||||||
|
"port": "বন্দর",
|
||||||
|
"useTls": "TLS (LDAPS) ব্যবহার করুন",
|
||||||
|
"bindDn": "ডিএনকে আবদ্ধ করুন",
|
||||||
|
"bindPassword": "পাসওয়ার্ড সংযুক্ত করুন",
|
||||||
|
"userSearchBase": "ব্যবহারকারী অনুসন্ধান ভিত্তি",
|
||||||
|
"userSearchFilter": "ব্যবহারকারী অনুসন্ধান ফিল্টার",
|
||||||
|
"usernameAttr": "ব্যবহারকারীর নাম অ্যাট্রিবিউট",
|
||||||
|
"displayNameAttr": "ডিসপ্লে নেম অ্যাট্রিবিউট",
|
||||||
|
"groupSearchBase": "গ্রুপ অনুসন্ধান ভিত্তি",
|
||||||
|
"adminGroup": "অ্যাডমিন গ্রুপ",
|
||||||
|
"allowedUsers": "অনুমোদিত ব্যবহারকারী"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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ó",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Veure documents",
|
||||||
|
"name": "Nom de visualització",
|
||||||
|
"enabled": "Activat",
|
||||||
|
"saving": "Desant...",
|
||||||
|
"saved": "Desat",
|
||||||
|
"saveFailed": "No s'ha pogut desar",
|
||||||
|
"deleted": "Suprimit",
|
||||||
|
"deleteFailed": "No s'ha pogut suprimir",
|
||||||
|
"cancel": "Cancel·la"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Amfitrió LDAP",
|
||||||
|
"port": "Port",
|
||||||
|
"useTls": "Utilitza TLS (LDAPS)",
|
||||||
|
"bindDn": "Vinculació de nom de domini",
|
||||||
|
"bindPassword": "Contrasenya de vinculació",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Zobrazit dokumenty",
|
||||||
|
"name": "Zobrazovaný název",
|
||||||
|
"enabled": "Povoleno",
|
||||||
|
"saving": "Ukládání...",
|
||||||
|
"saved": "Uloženo",
|
||||||
|
"saveFailed": "Uložení se nezdařilo",
|
||||||
|
"deleted": "Smazáno",
|
||||||
|
"deleteFailed": "Nepodařilo se smazat",
|
||||||
|
"cancel": "Zrušit"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Hostitel LDAP",
|
||||||
|
"port": "Přístav",
|
||||||
|
"useTls": "Používejte TLS (LDAPS)",
|
||||||
|
"bindDn": "Vázat DN",
|
||||||
|
"bindPassword": "Vázat heslo",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Log ind med {{name}}",
|
||||||
|
"loading": "Indlæser...",
|
||||||
|
"requiredField": "Dette felt er påkrævet",
|
||||||
|
"username": "LDAP-brugernavn",
|
||||||
|
"password": "LDAP-adgangskode",
|
||||||
|
"signIn": "Log ind",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Se dokumenter",
|
||||||
|
"name": "Vist navn",
|
||||||
|
"enabled": "Aktiveret",
|
||||||
|
"saving": "Gemmer...",
|
||||||
|
"saved": "Gemt",
|
||||||
|
"saveFailed": "Kunne ikke gemme",
|
||||||
|
"deleted": "Slettet",
|
||||||
|
"deleteFailed": "Kunne ikke slette",
|
||||||
|
"cancel": "Ophæve"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-vært",
|
||||||
|
"port": "Havn",
|
||||||
|
"useTls": "Brug TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind adgangskode",
|
||||||
|
"userSearchBase": "Brugersøgningsbase",
|
||||||
|
"userSearchFilter": "Brugersøgningsfilter",
|
||||||
|
"usernameAttr": "Brugernavnsattribut",
|
||||||
|
"displayNameAttr": "Visningsnavnattribut",
|
||||||
|
"groupSearchBase": "Gruppesøgningsbase",
|
||||||
|
"adminGroup": "Admingruppe",
|
||||||
|
"allowedUsers": "Tilladte brugere"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Anmeldung mit {{name}}",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"requiredField": "Dieses Feld ist erforderlich",
|
||||||
|
"username": "LDAP-Benutzername",
|
||||||
|
"password": "LDAP-Passwort",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Dokumentation anzeigen",
|
||||||
|
"name": "Anzeigename",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"saved": "Gespeichert",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
|
"deleted": "Gelöscht",
|
||||||
|
"deleteFailed": "Löschen fehlgeschlagen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-Host",
|
||||||
|
"port": "Port",
|
||||||
|
"useTls": "TLS verwenden (LDAPS)",
|
||||||
|
"bindDn": "Bind-DN",
|
||||||
|
"bindPassword": "Bind-Passwort",
|
||||||
|
"userSearchBase": "Benutzer-Suchbasis",
|
||||||
|
"userSearchFilter": "Benutzersuchfilter",
|
||||||
|
"usernameAttr": "Benutzernamen-Attribut",
|
||||||
|
"displayNameAttr": "Anzeigename-Attribut",
|
||||||
|
"groupSearchBase": "Gruppen-Suchbasis",
|
||||||
|
"adminGroup": "Admin-Gruppe",
|
||||||
|
"allowedUsers": "Erlaubte Benutzer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Σύνδεση με {{name}}",
|
||||||
|
"loading": "Φόρτωση...",
|
||||||
|
"requiredField": "Αυτό το πεδίο είναι υποχρεωτικό",
|
||||||
|
"username": "Όνομα χρήστη LDAP",
|
||||||
|
"password": "Κωδικός πρόσβασης LDAP",
|
||||||
|
"signIn": "Σύνδεση",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Προβολή εγγράφων",
|
||||||
|
"name": "Εμφανιζόμενο όνομα",
|
||||||
|
"enabled": "Ενεργοποιημένο",
|
||||||
|
"saving": "Οικονομία...",
|
||||||
|
"saved": "Αποθηκεύτηκε",
|
||||||
|
"saveFailed": "Η αποθήκευση απέτυχε",
|
||||||
|
"deleted": "Διαγράφηκε",
|
||||||
|
"deleteFailed": "Η διαγραφή απέτυχε",
|
||||||
|
"cancel": "Ματαίωση"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Κεντρικός υπολογιστής LDAP",
|
||||||
|
"port": "Λιμάνι",
|
||||||
|
"useTls": "Χρήση TLS (LDAPS)",
|
||||||
|
"bindDn": "Σύνδεση DN",
|
||||||
|
"bindPassword": "Σύνδεση κωδικού πρόσβασης",
|
||||||
|
"userSearchBase": "Βάση αναζήτησης χρηστών",
|
||||||
|
"userSearchFilter": "Φίλτρο αναζήτησης χρήστη",
|
||||||
|
"usernameAttr": "Χαρακτηριστικό ονόματος χρήστη",
|
||||||
|
"displayNameAttr": "Χαρακτηριστικό ονόματος εμφάνισης",
|
||||||
|
"groupSearchBase": "Βάση αναζήτησης ομάδας",
|
||||||
|
"adminGroup": "Ομάδα Διαχειριστών",
|
||||||
|
"allowedUsers": "Επιτρεπόμενοι χρήστες"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Ver documentación",
|
||||||
|
"name": "Nombre visible",
|
||||||
|
"enabled": "Activado",
|
||||||
|
"saving": "Guardando...",
|
||||||
|
"saved": "Guardado",
|
||||||
|
"saveFailed": "Error al guardar",
|
||||||
|
"deleted": "Eliminado",
|
||||||
|
"deleteFailed": "Error al eliminar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Host LDAP",
|
||||||
|
"port": "Puerto",
|
||||||
|
"useTls": "Usar TLS (LDAPS)",
|
||||||
|
"bindDn": "DN de enlace",
|
||||||
|
"bindPassword": "Contraseña de enlace",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Näytä dokumentit",
|
||||||
|
"name": "Näyttönimi",
|
||||||
|
"enabled": "Käytössä",
|
||||||
|
"saving": "Tallennetaan...",
|
||||||
|
"saved": "Tallennettu",
|
||||||
|
"saveFailed": "Tallennus epäonnistui",
|
||||||
|
"deleted": "Poistettu",
|
||||||
|
"deleteFailed": "Poistaminen epäonnistui",
|
||||||
|
"cancel": "Peruuttaa"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-isäntä",
|
||||||
|
"port": "Portti",
|
||||||
|
"useTls": "Käytä TLS:ää (LDAPS)",
|
||||||
|
"bindDn": "Sido DN",
|
||||||
|
"bindPassword": "Sido salasana",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Documentation",
|
||||||
|
"name": "Nom d’affichage",
|
||||||
|
"enabled": "Activé",
|
||||||
|
"saving": "Enregistrement…",
|
||||||
|
"saved": "Enregistré",
|
||||||
|
"saveFailed": "Échec de l'enregistrement",
|
||||||
|
"deleted": "Supprimé",
|
||||||
|
"deleteFailed": "Échec de la suppression",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Hôte LDAP",
|
||||||
|
"port": "Port",
|
||||||
|
"useTls": "Utiliser TLS (LDAPS)",
|
||||||
|
"bindDn": "DN de liaison",
|
||||||
|
"bindPassword": "Mot de passe de liaison",
|
||||||
|
"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 admin",
|
||||||
|
"allowedUsers": "Utilisateurs autorisés"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "התחברות באמצעות {{name}}",
|
||||||
|
"loading": "טְעִינָה...",
|
||||||
|
"requiredField": "שדה זה נדרש",
|
||||||
|
"username": "שם משתמש LDAP",
|
||||||
|
"password": "סיסמת LDAP",
|
||||||
|
"signIn": "כניסה",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "הצג מסמכים",
|
||||||
|
"name": "שם תצוגה",
|
||||||
|
"enabled": "מופעל",
|
||||||
|
"saving": "חִסָכוֹן...",
|
||||||
|
"saved": "נשמר",
|
||||||
|
"saveFailed": "השמירה נכשלה",
|
||||||
|
"deleted": "נמחק",
|
||||||
|
"deleteFailed": "המחיקה נכשלה",
|
||||||
|
"cancel": "לְבַטֵל"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "מארח LDAP",
|
||||||
|
"port": "נָמָל",
|
||||||
|
"useTls": "השתמש ב-TLS (LDAPS)",
|
||||||
|
"bindDn": "קשר DN",
|
||||||
|
"bindPassword": "קשירת סיסמה",
|
||||||
|
"userSearchBase": "בסיס חיפוש משתמשים",
|
||||||
|
"userSearchFilter": "מסנן חיפוש משתמשים",
|
||||||
|
"usernameAttr": "מאפיין שם משתמש",
|
||||||
|
"displayNameAttr": "מאפיין שם תצוגה",
|
||||||
|
"groupSearchBase": "בסיס חיפוש קבוצתי",
|
||||||
|
"adminGroup": "קבוצת ניהול",
|
||||||
|
"allowedUsers": "משתמשים מורשים"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}} से लॉगिन करें",
|
||||||
|
"loading": "लोड हो रहा है...",
|
||||||
|
"requiredField": "यह फ़ील्ड आवश्यक है",
|
||||||
|
"username": "LDAP उपयोगकर्ता नाम",
|
||||||
|
"password": "LDAP पासवर्ड",
|
||||||
|
"signIn": "साइन इन करें",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "दस्तावेज़ देखें",
|
||||||
|
"name": "प्रदर्शन नाम",
|
||||||
|
"enabled": "सक्षम",
|
||||||
|
"saving": "सहेजा जा रहा है...",
|
||||||
|
"saved": "सहेजा गया",
|
||||||
|
"saveFailed": "सहेजने में विफल",
|
||||||
|
"deleted": "हटाया गया",
|
||||||
|
"deleteFailed": "हटाने में विफल",
|
||||||
|
"cancel": "रद्द करें"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP होस्ट",
|
||||||
|
"port": "पोर्ट",
|
||||||
|
"useTls": "TLS (LDAPS) का उपयोग करें",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind पासवर्ड",
|
||||||
|
"userSearchBase": "उपयोगकर्ता खोज आधार",
|
||||||
|
"userSearchFilter": "उपयोगकर्ता खोज फ़िल्टर",
|
||||||
|
"usernameAttr": "उपयोगकर्ता नाम विशेषता",
|
||||||
|
"displayNameAttr": "प्रदर्शन नाम विशेषता",
|
||||||
|
"groupSearchBase": "समूह खोज आधार",
|
||||||
|
"adminGroup": "एडमिन समूह",
|
||||||
|
"allowedUsers": "अनुमत उपयोगकर्ता"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Dokumentumok megtekintése",
|
||||||
|
"name": "Megjelenített név",
|
||||||
|
"enabled": "Engedélyezve",
|
||||||
|
"saving": "Megtakarítás...",
|
||||||
|
"saved": "Mentett",
|
||||||
|
"saveFailed": "Nem sikerült menteni",
|
||||||
|
"deleted": "Törölt",
|
||||||
|
"deleteFailed": "Nem sikerült törölni",
|
||||||
|
"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",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Masuk dengan {{name}}",
|
||||||
|
"loading": "Memuat...",
|
||||||
|
"requiredField": "Kolom ini wajib diisi.",
|
||||||
|
"username": "Nama Pengguna LDAP",
|
||||||
|
"password": "Kata Sandi LDAP",
|
||||||
|
"signIn": "Masuk",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Lihat dokumen",
|
||||||
|
"name": "Nama Tampilan",
|
||||||
|
"enabled": "Diaktifkan",
|
||||||
|
"saving": "Penghematan...",
|
||||||
|
"saved": "Tersimpan",
|
||||||
|
"saveFailed": "Gagal menyimpan",
|
||||||
|
"deleted": "Dihapus",
|
||||||
|
"deleteFailed": "Gagal menghapus",
|
||||||
|
"cancel": "Membatalkan"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Host LDAP",
|
||||||
|
"port": "Pelabuhan",
|
||||||
|
"useTls": "Gunakan TLS (LDAPS)",
|
||||||
|
"bindDn": "Ikat DN",
|
||||||
|
"bindPassword": "Kata Sandi Terikat",
|
||||||
|
"userSearchBase": "Basis Pencarian Pengguna",
|
||||||
|
"userSearchFilter": "Filter Pencarian Pengguna",
|
||||||
|
"usernameAttr": "Atribut Nama Pengguna",
|
||||||
|
"displayNameAttr": "Atribut Nama Tampilan",
|
||||||
|
"groupSearchBase": "Basis Pencarian Grup",
|
||||||
|
"adminGroup": "Grup Admin",
|
||||||
|
"allowedUsers": "Pengguna yang Diizinkan"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Accedi con {{name}}",
|
||||||
|
"loading": "Caricamento in corso...",
|
||||||
|
"requiredField": "Questo campo è obbligatorio",
|
||||||
|
"username": "Nome utente LDAP",
|
||||||
|
"password": "Password LDAP",
|
||||||
|
"signIn": "Accedi",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Visualizza documentazione",
|
||||||
|
"name": "Nome visualizzato",
|
||||||
|
"enabled": "Abilitato",
|
||||||
|
"saving": "Salvataggio in corso...",
|
||||||
|
"saved": "Salvato",
|
||||||
|
"saveFailed": "Salvataggio non riuscito",
|
||||||
|
"deleted": "Eliminato",
|
||||||
|
"deleteFailed": "Eliminazione non riuscita",
|
||||||
|
"cancel": "Annulla"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Host LDAP",
|
||||||
|
"port": "Porta",
|
||||||
|
"useTls": "Usa TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Password di bind",
|
||||||
|
"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 autorizzati"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}}でログイン",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"requiredField": "この項目は必須です",
|
||||||
|
"username": "LDAPユーザー名",
|
||||||
|
"password": "LDAPパスワード",
|
||||||
|
"signIn": "サインイン",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "ドキュメントを表示",
|
||||||
|
"name": "表示名",
|
||||||
|
"enabled": "有効",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "保存しました",
|
||||||
|
"saveFailed": "保存に失敗しました",
|
||||||
|
"deleted": "削除しました",
|
||||||
|
"deleteFailed": "削除に失敗しました",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAPホスト",
|
||||||
|
"port": "ポート",
|
||||||
|
"useTls": "TLS (LDAPS) を使用",
|
||||||
|
"bindDn": "バインドDN",
|
||||||
|
"bindPassword": "バインドパスワード",
|
||||||
|
"userSearchBase": "ユーザー検索ベース",
|
||||||
|
"userSearchFilter": "ユーザー検索フィルター",
|
||||||
|
"usernameAttr": "ユーザー名属性",
|
||||||
|
"displayNameAttr": "表示名属性",
|
||||||
|
"groupSearchBase": "グループ検索ベース",
|
||||||
|
"adminGroup": "管理者グループ",
|
||||||
|
"allowedUsers": "許可ユーザー"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}}으로 로그인",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"requiredField": "필수 입력란입니다",
|
||||||
|
"username": "LDAP 사용자 이름",
|
||||||
|
"password": "LDAP 비밀번호",
|
||||||
|
"signIn": "로그인",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "문서 보기",
|
||||||
|
"name": "표시 이름",
|
||||||
|
"enabled": "사용",
|
||||||
|
"saving": "저장 중...",
|
||||||
|
"saved": "저장됨",
|
||||||
|
"saveFailed": "저장 실패",
|
||||||
|
"deleted": "삭제됨",
|
||||||
|
"deleteFailed": "삭제 실패",
|
||||||
|
"cancel": "취소"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP 호스트",
|
||||||
|
"port": "포트",
|
||||||
|
"useTls": "TLS 사용 (LDAPS)",
|
||||||
|
"bindDn": "바인딩 DN",
|
||||||
|
"bindPassword": "바인딩 암호",
|
||||||
|
"userSearchBase": "사용자 검색 기준",
|
||||||
|
"userSearchFilter": "사용자 검색 필터",
|
||||||
|
"usernameAttr": "사용자 이름 속성",
|
||||||
|
"displayNameAttr": "표시 이름 속성",
|
||||||
|
"groupSearchBase": "그룹 검색 기준",
|
||||||
|
"adminGroup": "관리자 그룹",
|
||||||
|
"allowedUsers": "허용 사용자"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Inloggen met {{name}}",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"requiredField": "Dit veld is verplicht.",
|
||||||
|
"username": "LDAP-gebruikersnaam",
|
||||||
|
"password": "LDAP-wachtwoord",
|
||||||
|
"signIn": "Inloggen",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Documenten bekijken",
|
||||||
|
"name": "Weergavenaam",
|
||||||
|
"enabled": "Ingeschakeld",
|
||||||
|
"saving": "Besparing...",
|
||||||
|
"saved": "Opgeslagen",
|
||||||
|
"saveFailed": "Opslaan mislukt",
|
||||||
|
"deleted": "Verwijderd",
|
||||||
|
"deleteFailed": "Verwijderen mislukt",
|
||||||
|
"cancel": "Annuleren"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-host",
|
||||||
|
"port": "Haven",
|
||||||
|
"useTls": "Gebruik TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind wachtwoord",
|
||||||
|
"userSearchBase": "Gebruikerszoekbasis",
|
||||||
|
"userSearchFilter": "Gebruikerszoekfilter",
|
||||||
|
"usernameAttr": "Gebruikersnaamkenmerk",
|
||||||
|
"displayNameAttr": "Weergavenaamkenmerk",
|
||||||
|
"groupSearchBase": "Groepszoekbasis",
|
||||||
|
"adminGroup": "Beheerdersgroep",
|
||||||
|
"allowedUsers": "Toegestane gebruikers"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Logg inn med {{name}}",
|
||||||
|
"loading": "Laster inn...",
|
||||||
|
"requiredField": "Dette feltet er obligatorisk",
|
||||||
|
"username": "LDAP-brukernavn",
|
||||||
|
"password": "LDAP-passord",
|
||||||
|
"signIn": "Logg inn",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Vis dokumenter",
|
||||||
|
"name": "Visningsnavn",
|
||||||
|
"enabled": "Aktivert",
|
||||||
|
"saving": "Lagrer...",
|
||||||
|
"saved": "Lagret",
|
||||||
|
"saveFailed": "Kunne ikke lagre",
|
||||||
|
"deleted": "Slettet",
|
||||||
|
"deleteFailed": "Kunne ikke slette",
|
||||||
|
"cancel": "Kansellere"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-vert",
|
||||||
|
"port": "Havn",
|
||||||
|
"useTls": "Bruk TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind passord",
|
||||||
|
"userSearchBase": "Brukersøkbase",
|
||||||
|
"userSearchFilter": "Brukersøkfilter",
|
||||||
|
"usernameAttr": "Brukernavnattributt",
|
||||||
|
"displayNameAttr": "Visningsnavnattributt",
|
||||||
|
"groupSearchBase": "Gruppesøkbase",
|
||||||
|
"adminGroup": "Admingruppe",
|
||||||
|
"allowedUsers": "Tillatte brukere"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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ę",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Zobacz dokumentację",
|
||||||
|
"name": "Nazwa wyświetlana",
|
||||||
|
"enabled": "Włączone",
|
||||||
|
"saving": "Zapisywanie...",
|
||||||
|
"saved": "Zapisano",
|
||||||
|
"saveFailed": "Nie udało się zapisać",
|
||||||
|
"deleted": "Usunięto",
|
||||||
|
"deleteFailed": "Nie udało się usunąć",
|
||||||
|
"cancel": "Anuluj"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Host LDAP",
|
||||||
|
"port": "Port",
|
||||||
|
"useTls": "Użyj TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Hasło Bind",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Faça login com {{name}}",
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"requiredField": "Este campo é obrigatório",
|
||||||
|
"username": "Nome de usuário LDAP",
|
||||||
|
"password": "Senha LDAP",
|
||||||
|
"signIn": "Entrar",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Ver documentos",
|
||||||
|
"name": "Nome de exibição",
|
||||||
|
"enabled": "Habilitado",
|
||||||
|
"saving": "Salvando...",
|
||||||
|
"saved": "Salvo",
|
||||||
|
"saveFailed": "Falha ao salvar",
|
||||||
|
"deleted": "Excluído",
|
||||||
|
"deleteFailed": "Falha ao excluir",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Host LDAP",
|
||||||
|
"port": "Porta",
|
||||||
|
"useTls": "Usar TLS (LDAPS)",
|
||||||
|
"bindDn": "Vincular DN",
|
||||||
|
"bindPassword": "Senha de vinculação",
|
||||||
|
"userSearchBase": "Base de pesquisa de usuários",
|
||||||
|
"userSearchFilter": "Filtro de pesquisa do usuário",
|
||||||
|
"usernameAttr": "Atributo de nome de usuário",
|
||||||
|
"displayNameAttr": "Atributo de nome de exibição",
|
||||||
|
"groupSearchBase": "Base de pesquisa de grupo",
|
||||||
|
"adminGroup": "Grupo de Administração",
|
||||||
|
"allowedUsers": "Usuários autorizados"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Ver docs",
|
||||||
|
"name": "Nome de apresentação",
|
||||||
|
"enabled": "Ativado",
|
||||||
|
"saving": "A guardar...",
|
||||||
|
"saved": "Guardado",
|
||||||
|
"saveFailed": "Falha ao guardar",
|
||||||
|
"deleted": "Eliminado",
|
||||||
|
"deleteFailed": "Falha ao eliminar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Servidor LDAP",
|
||||||
|
"port": "Porta",
|
||||||
|
"useTls": "Usar TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Palavra-passe de Bind",
|
||||||
|
"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 administradores",
|
||||||
|
"allowedUsers": "Utilizadores permitidos"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Autentificare cu {{name}}",
|
||||||
|
"loading": "Încărcare...",
|
||||||
|
"requiredField": "Acest câmp este obligatoriu",
|
||||||
|
"username": "Nume de utilizator LDAP",
|
||||||
|
"password": "Parolă LDAP",
|
||||||
|
"signIn": "Conectare",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Vizualizați documentele",
|
||||||
|
"name": "Numele de afișare",
|
||||||
|
"enabled": "Activat",
|
||||||
|
"saving": "Economisire...",
|
||||||
|
"saved": "Salvat",
|
||||||
|
"saveFailed": "Salvarea nu a reușit",
|
||||||
|
"deleted": "Șters",
|
||||||
|
"deleteFailed": "Ștergerea nu a reușit",
|
||||||
|
"cancel": "Anula"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Gazdă LDAP",
|
||||||
|
"port": "Port",
|
||||||
|
"useTls": "Utilizați TLS (LDAPS)",
|
||||||
|
"bindDn": "Legați DN-ul",
|
||||||
|
"bindPassword": "Parolă de legare",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Войти через {{name}}",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"requiredField": "Обязательное поле",
|
||||||
|
"username": "Имя пользователя LDAP",
|
||||||
|
"password": "Пароль LDAP",
|
||||||
|
"signIn": "Войти",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Документация",
|
||||||
|
"name": "Отображаемое имя",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"saveFailed": "Не удалось сохранить",
|
||||||
|
"deleted": "Удалено",
|
||||||
|
"deleteFailed": "Не удалось удалить",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-сервер",
|
||||||
|
"port": "Порт",
|
||||||
|
"useTls": "Использовать TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Пароль привязки",
|
||||||
|
"userSearchBase": "База поиска пользователей",
|
||||||
|
"userSearchFilter": "Фильтр поиска пользователей",
|
||||||
|
"usernameAttr": "Атрибут имени пользователя",
|
||||||
|
"displayNameAttr": "Атрибут отображаемого имени",
|
||||||
|
"groupSearchBase": "База поиска групп",
|
||||||
|
"adminGroup": "Группа администраторов",
|
||||||
|
"allowedUsers": "Разрешенные пользователи"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Пријавите се са {{name}}",
|
||||||
|
"loading": "Учитавање...",
|
||||||
|
"requiredField": "Ово поље је обавезно",
|
||||||
|
"username": "LDAP корисничко име",
|
||||||
|
"password": "LDAP лозинка",
|
||||||
|
"signIn": "Пријавите се",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Прикажи документе",
|
||||||
|
"name": "Приказано име",
|
||||||
|
"enabled": "Омогућено",
|
||||||
|
"saving": "Чување...",
|
||||||
|
"saved": "Сачувано",
|
||||||
|
"saveFailed": "Чување није успело",
|
||||||
|
"deleted": "Избрисано",
|
||||||
|
"deleteFailed": "Брисање није успело",
|
||||||
|
"cancel": "Откажи"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP хост",
|
||||||
|
"port": "Лука",
|
||||||
|
"useTls": "Користите TLS (LDAPS)",
|
||||||
|
"bindDn": "Повезивање DN-а",
|
||||||
|
"bindPassword": "Повезивање лозинке",
|
||||||
|
"userSearchBase": "База претраге корисника",
|
||||||
|
"userSearchFilter": "Филтер за претрагу корисника",
|
||||||
|
"usernameAttr": "Атрибут корисничког имена",
|
||||||
|
"displayNameAttr": "Атрибут приказаног имена",
|
||||||
|
"groupSearchBase": "База групне претраге",
|
||||||
|
"adminGroup": "Администраторска група",
|
||||||
|
"allowedUsers": "Дозвољени корисници"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Logga in med {{name}}",
|
||||||
|
"loading": "Belastning...",
|
||||||
|
"requiredField": "Detta fält är obligatoriskt",
|
||||||
|
"username": "LDAP-användarnamn",
|
||||||
|
"password": "LDAP-lösenord",
|
||||||
|
"signIn": "Logga in",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Visa dokument",
|
||||||
|
"name": "Visningsnamn",
|
||||||
|
"enabled": "Aktiverad",
|
||||||
|
"saving": "Sparande...",
|
||||||
|
"saved": "Sparad",
|
||||||
|
"saveFailed": "Misslyckades med att spara",
|
||||||
|
"deleted": "Borttagen",
|
||||||
|
"deleteFailed": "Misslyckades med att radera",
|
||||||
|
"cancel": "Avboka"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP-värd",
|
||||||
|
"port": "Hamn",
|
||||||
|
"useTls": "Använd TLS (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind lösenord",
|
||||||
|
"userSearchBase": "Användarsökningsbas",
|
||||||
|
"userSearchFilter": "Användarsökningsfilter",
|
||||||
|
"usernameAttr": "Användarnamnsattribut",
|
||||||
|
"displayNameAttr": "Attribut för visningsnamn",
|
||||||
|
"groupSearchBase": "Gruppsökningsbas",
|
||||||
|
"adminGroup": "Administratörsgrupp",
|
||||||
|
"allowedUsers": "Tillåtna användare"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "เข้าสู่ระบบด้วย {{name}}",
|
||||||
|
"loading": "กำลังโหลด...",
|
||||||
|
"requiredField": "ช่องนี้จำเป็นต้องกรอก",
|
||||||
|
"username": "ชื่อผู้ใช้ LDAP",
|
||||||
|
"password": "รหัสผ่าน LDAP",
|
||||||
|
"signIn": "เข้าสู่ระบบ",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "ดูเอกสาร",
|
||||||
|
"name": "ชื่อที่แสดง",
|
||||||
|
"enabled": "เปิดใช้งาน",
|
||||||
|
"saving": "ประหยัด...",
|
||||||
|
"saved": "บันทึกแล้ว",
|
||||||
|
"saveFailed": "บันทึกไม่สำเร็จ",
|
||||||
|
"deleted": "ลบแล้ว",
|
||||||
|
"deleteFailed": "ลบไม่สำเร็จ",
|
||||||
|
"cancel": "ยกเลิก"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "โฮสต์ LDAP",
|
||||||
|
"port": "ท่าเรือ",
|
||||||
|
"useTls": "ใช้ TLS (LDAPS)",
|
||||||
|
"bindDn": "ผูก DN",
|
||||||
|
"bindPassword": "รหัสผ่านผูกมัด",
|
||||||
|
"userSearchBase": "ฐานการค้นหาผู้ใช้",
|
||||||
|
"userSearchFilter": "ตัวกรองการค้นหาผู้ใช้",
|
||||||
|
"usernameAttr": "คุณลักษณะชื่อผู้ใช้",
|
||||||
|
"displayNameAttr": "คุณลักษณะชื่อที่แสดง",
|
||||||
|
"groupSearchBase": "ฐานการค้นหากลุ่ม",
|
||||||
|
"adminGroup": "กลุ่มผู้ดูแลระบบ",
|
||||||
|
"allowedUsers": "ผู้ใช้ที่ได้รับอนุญาต"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}} ile giriş yap",
|
||||||
|
"loading": "Yükleniyor...",
|
||||||
|
"requiredField": "Bu alan zorunludur",
|
||||||
|
"username": "LDAP Kullanıcı Adı",
|
||||||
|
"password": "LDAP Parolası",
|
||||||
|
"signIn": "Oturum Aç",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Belgeleri görüntüle",
|
||||||
|
"name": "Görünen Ad",
|
||||||
|
"enabled": "Etkin",
|
||||||
|
"saving": "Kaydediliyor...",
|
||||||
|
"saved": "Kaydedildi",
|
||||||
|
"saveFailed": "Kaydedilemedi",
|
||||||
|
"deleted": "Silindi",
|
||||||
|
"deleteFailed": "Silinemedi",
|
||||||
|
"cancel": "İptal"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP Sunucusu",
|
||||||
|
"port": "Port",
|
||||||
|
"useTls": "TLS Kullan (LDAPS)",
|
||||||
|
"bindDn": "Bind DN",
|
||||||
|
"bindPassword": "Bind Parolası",
|
||||||
|
"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,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Увійти за допомогою {{name}}",
|
||||||
|
"loading": "Завантаження...",
|
||||||
|
"requiredField": "Це поле обов'язкове",
|
||||||
|
"username": "Ім'я користувача LDAP",
|
||||||
|
"password": "Пароль LDAP",
|
||||||
|
"signIn": "Увійти",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Переглянути документи",
|
||||||
|
"name": "Відображуване ім'я",
|
||||||
|
"enabled": "Увімкнено",
|
||||||
|
"saving": "Збереження...",
|
||||||
|
"saved": "Збережено",
|
||||||
|
"saveFailed": "Не вдалося зберегти",
|
||||||
|
"deleted": "Видалено",
|
||||||
|
"deleteFailed": "Не вдалося видалити",
|
||||||
|
"cancel": "Скасувати"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "Хост LDAP",
|
||||||
|
"port": "Порт",
|
||||||
|
"useTls": "Використовувати TLS (LDAPS)",
|
||||||
|
"bindDn": "Прив'язане DN",
|
||||||
|
"bindPassword": "Прив’язати пароль",
|
||||||
|
"userSearchBase": "База пошуку користувачів",
|
||||||
|
"userSearchFilter": "Фільтр пошуку користувачів",
|
||||||
|
"usernameAttr": "Атрибут імені користувача",
|
||||||
|
"displayNameAttr": "Атрибут відображуваного імені",
|
||||||
|
"groupSearchBase": "База групового пошуку",
|
||||||
|
"adminGroup": "Група адміністраторів",
|
||||||
|
"allowedUsers": "Дозволені користувачі"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Xem tài liệu",
|
||||||
|
"name": "Tên hiển thị",
|
||||||
|
"enabled": "Đã bật",
|
||||||
|
"saving": "Đang lưu...",
|
||||||
|
"saved": "Đã lưu",
|
||||||
|
"saveFailed": "Không thể lưu",
|
||||||
|
"deleted": "Đã xóa",
|
||||||
|
"deleteFailed": "Không thể xóa",
|
||||||
|
"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",
|
||||||
|
"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 quản trị",
|
||||||
|
"allowedUsers": "Người dùng được phép"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "使用{{name}}登录",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"requiredField": "此字段为必填项",
|
||||||
|
"username": "LDAP 用户名",
|
||||||
|
"password": "LDAP 密码",
|
||||||
|
"signIn": "登录",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "查看文档",
|
||||||
|
"name": "显示名称",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "已保存",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"deleted": "已删除",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP 主机",
|
||||||
|
"port": "端口",
|
||||||
|
"useTls": "使用 TLS (LDAPS)",
|
||||||
|
"bindDn": "绑定 DN",
|
||||||
|
"bindPassword": "绑定密码",
|
||||||
|
"userSearchBase": "用户搜索基准",
|
||||||
|
"userSearchFilter": "用户搜索过滤器",
|
||||||
|
"usernameAttr": "用户名属性",
|
||||||
|
"displayNameAttr": "显示名称属性",
|
||||||
|
"groupSearchBase": "群组搜索基准",
|
||||||
|
"adminGroup": "管理员群组",
|
||||||
|
"allowedUsers": "允许的用户"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "使用 {{name}} 登入",
|
||||||
|
"loading": "載入中...",
|
||||||
|
"requiredField": "此欄位為必填",
|
||||||
|
"username": "LDAP 使用者名稱",
|
||||||
|
"password": "LDAP 密碼",
|
||||||
|
"signIn": "登入",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "檢視文件",
|
||||||
|
"name": "顯示名稱",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"saving": "儲存中...",
|
||||||
|
"saved": "已儲存",
|
||||||
|
"saveFailed": "儲存失敗",
|
||||||
|
"deleted": "已刪除",
|
||||||
|
"deleteFailed": "刪除失敗",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"host": "LDAP 主機",
|
||||||
|
"port": "連接埠",
|
||||||
|
"useTls": "使用 TLS (LDAPS)",
|
||||||
|
"bindDn": "綁定 DN",
|
||||||
|
"bindPassword": "綁定密碼",
|
||||||
|
"userSearchBase": "使用者搜尋基底",
|
||||||
|
"userSearchFilter": "使用者搜尋篩選器",
|
||||||
|
"usernameAttr": "使用者名稱屬性",
|
||||||
|
"displayNameAttr": "顯示名稱屬性",
|
||||||
|
"groupSearchBase": "群組搜尋基底",
|
||||||
|
"adminGroup": "管理員群組",
|
||||||
|
"allowedUsers": "允許的使用者"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -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,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@termix-plugin/ldap",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "termix-plugin build",
|
||||||
|
"test": "termix-plugin test",
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"validate": "termix-plugin validate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ldapjs": "^3.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@termix/plugin-sdk": "*",
|
||||||
|
"@types/ldapjs": "^3.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,205 @@
|
|||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (entries.length === 0) throw await refuse("user not found");
|
||||||
|
|
||||||
|
const userEntry = entries[0];
|
||||||
|
const userDN = 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,304 @@
|
|||||||
|
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", () => {
|
||||||
|
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 },
|
||||||
|
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: { toString: () => user.dn },
|
||||||
|
attributes: Object.entries(user.attrs).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("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);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"loginWithSso": "Sign in with SSO",
|
||||||
|
"loginWithProvider": "Login with {{name}}",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"permissions": {
|
||||||
|
"manage": {
|
||||||
|
"title": "Manage SSO providers",
|
||||||
|
"description": "Add, change and remove the SSO providers people can sign in with."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "View docs",
|
||||||
|
"none": "No SSO providers configured.",
|
||||||
|
"add": "Add Provider",
|
||||||
|
"edit": "Edit Provider",
|
||||||
|
"delete": "Delete Provider",
|
||||||
|
"deleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||||
|
"name": "Display Name",
|
||||||
|
"namePlaceholder": "My SSO provider",
|
||||||
|
"nameRequired": "A display name is required",
|
||||||
|
"type": "Provider Type",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"save": "Save Provider",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saved": "Saved",
|
||||||
|
"saveFailed": "Failed to save",
|
||||||
|
"deleted": "Deleted",
|
||||||
|
"deleteFailed": "Failed to delete",
|
||||||
|
"loadFailed": "Failed to load SSO providers",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"redirectUri": "Redirect URI",
|
||||||
|
"redirectUriDesc": "Register this URL as the redirect or callback URL in your identity provider.",
|
||||||
|
"legacyCallback": "Keep the old redirect URI",
|
||||||
|
"legacyCallbackDesc": "This provider was set up before Termix 2.9 and still uses the old redirect URI, which keeps working. To move to the new one, add {{uri}} in your identity provider first, then turn this off.",
|
||||||
|
"authorizationUrl": "Authorization URL: {{url}}"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Client ID",
|
||||||
|
"clientSecret": "Client Secret",
|
||||||
|
"secretKeep": "A secret is saved. Leave this empty to keep it.",
|
||||||
|
"issuerUrl": "Issuer URL",
|
||||||
|
"authUrl": "Authorization URL",
|
||||||
|
"tokenUrl": "Token URL",
|
||||||
|
"userIdentifier": "User Identifier Path",
|
||||||
|
"displayName": "Display Name Path",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"userinfoUrl": "Override Userinfo URL",
|
||||||
|
"groupClaim": "Group Claim",
|
||||||
|
"groupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||||
|
"allowedUsers": "Allowed Users",
|
||||||
|
"allowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||||
|
"adminGroup": "Admin Group",
|
||||||
|
"adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||||
|
"caCert": "Custom CA Certificate",
|
||||||
|
"caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Teken aan met {{name}}",
|
||||||
|
"loading": "Laai tans...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Bekyk dokumente",
|
||||||
|
"none": "Geen SSO-verskaffers gekonfigureer nie.",
|
||||||
|
"add": "Voeg Verskaffer by",
|
||||||
|
"edit": "Wysig Verskaffer",
|
||||||
|
"delete": "Vee Verskaffer uit",
|
||||||
|
"deleteConfirm": "Vee hierdie verskaffer uit? Geassosieerde gebruikers sal nie kan aanmeld nie.",
|
||||||
|
"name": "Vertoonnaam",
|
||||||
|
"type": "Verskaffertipe",
|
||||||
|
"enabled": "Geaktiveer",
|
||||||
|
"save": "Stoor Verskaffer",
|
||||||
|
"saving": "Stoor...",
|
||||||
|
"saved": "Gestoor",
|
||||||
|
"saveFailed": "Kon nie stoor nie",
|
||||||
|
"deleted": "Verwyder",
|
||||||
|
"deleteFailed": "Kon nie verwyder nie",
|
||||||
|
"cancel": "Kanselleer"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Kliënt-ID",
|
||||||
|
"clientSecret": "Kliëntgeheim",
|
||||||
|
"issuerUrl": "Uitreiker-URL",
|
||||||
|
"authUrl": "Magtigings-URL",
|
||||||
|
"tokenUrl": "Teken-URL",
|
||||||
|
"userIdentifier": "Gebruikersidentifiseerderpad",
|
||||||
|
"displayName": "Vertoonnaampad",
|
||||||
|
"scopes": "Omvangsgebiede",
|
||||||
|
"userinfoUrl": "Oorskryf Gebruikersinligting URL",
|
||||||
|
"groupClaim": "Groep Eis",
|
||||||
|
"groupClaimDesc": "Opsioneel. Die eispad wat die gebruiker se groepe bevat. Standaard is groepe, rolle, dan groep. Gebruik dit vir verskaffers met 'n persoonlike eis (bv. Zitadel).",
|
||||||
|
"allowedUsers": "Toegelate Gebruikers",
|
||||||
|
"allowedUsersDesc": "Een e-pos per reël. Los leeg om alles toe te laat.",
|
||||||
|
"adminGroup": "Admingroep",
|
||||||
|
"adminGroupDesc": "Gebruikers in hierdie groep kry administrasie. Los leeg om groepsinchronisasie te deaktiveer.",
|
||||||
|
"caCert": "Pasgemaakte CA-sertifikaat",
|
||||||
|
"caCertDesc": "Opsioneel. PEM-geënkodeerde CA-sertifikaat vir OIDC-verskaffers wat 'n private of selfgetekende CA gebruik. Los leeg om die stelselvertrouensberging te gebruik."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "تسجيل الدخول باستخدام {{name}}",
|
||||||
|
"loading": "جارٍ التحميل...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "عرض المستندات",
|
||||||
|
"none": "لم يتم تكوين أي مزود SSO.",
|
||||||
|
"add": "إضافة مزود",
|
||||||
|
"edit": "تعديل المزود",
|
||||||
|
"delete": "حذف المزود",
|
||||||
|
"deleteConfirm": "حذف هذا المزود؟ لن يتمكن المستخدمون المرتبطون من تسجيل الدخول.",
|
||||||
|
"name": "الاسم المعروض",
|
||||||
|
"type": "نوع المزود",
|
||||||
|
"enabled": "مُمكّن",
|
||||||
|
"save": "حفظ المزود",
|
||||||
|
"saving": "جارٍ الحفظ...",
|
||||||
|
"saved": "محفوظ",
|
||||||
|
"saveFailed": "فشل في الحفظ",
|
||||||
|
"deleted": "محذوف",
|
||||||
|
"deleteFailed": "فشل في الحذف",
|
||||||
|
"cancel": "إلغاء"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "معرف العميل",
|
||||||
|
"clientSecret": "سر العميل",
|
||||||
|
"issuerUrl": "رابط المُصدر",
|
||||||
|
"authUrl": "رابط التفويض",
|
||||||
|
"tokenUrl": "رابط الرمز المميز",
|
||||||
|
"userIdentifier": "مسار معرّف المستخدم",
|
||||||
|
"displayName": "مسار اسم العرض",
|
||||||
|
"scopes": "النطاقات",
|
||||||
|
"userinfoUrl": "تجاوز رابط Userinfo",
|
||||||
|
"groupClaim": "مطالبة المجموعة",
|
||||||
|
"groupClaimDesc": "اختياري. مسار المطالبة الذي يحتوي على مجموعات المستخدم. القيمة الافتراضية هي groups، ثم roles، ثم group. استخدم هذا للموفّرين الذين لديهم مطالبة مخصصة (مثل Zitadel).",
|
||||||
|
"allowedUsers": "المستخدمون المسموح لهم",
|
||||||
|
"allowedUsersDesc": "بريد إلكتروني واحد في كل سطر. اتركه فارغًا للسماح للجميع.",
|
||||||
|
"adminGroup": "مجموعة المسؤولين",
|
||||||
|
"adminGroupDesc": "يُمنح المستخدمون في هذه المجموعة صلاحيات المسؤول. اتركه فارغًا لتعطيل مزامنة المجموعة.",
|
||||||
|
"caCert": "شهادة CA مخصصة",
|
||||||
|
"caCertDesc": "اختياري. شهادة CA بترميز PEM لموفّري OIDC الذين يستخدمون شهادة CA خاصة أو موقّعة ذاتيًا. اتركه فارغًا لاستخدام مخزن الثقة في النظام."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Влезте с {{name}}",
|
||||||
|
"loading": "Зареждане...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Преглед на документи",
|
||||||
|
"none": "Няма конфигурирани доставчици на SSO.",
|
||||||
|
"add": "Добавяне на доставчик",
|
||||||
|
"edit": "Редактиране на доставчик",
|
||||||
|
"delete": "Изтриване на доставчик",
|
||||||
|
"deleteConfirm": "Да се изтрие ли този доставчик? Свързаните потребители няма да могат да влязат.",
|
||||||
|
"name": "Показвано име",
|
||||||
|
"type": "Тип доставчик",
|
||||||
|
"enabled": "Активирано",
|
||||||
|
"save": "Запазване на доставчика",
|
||||||
|
"saving": "Запазване...",
|
||||||
|
"saved": "Запазено",
|
||||||
|
"saveFailed": "Запазването не бе успешно",
|
||||||
|
"deleted": "Изтрито",
|
||||||
|
"deleteFailed": "Изтриването не бе успешно",
|
||||||
|
"cancel": "Отказ"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Идентификационен номер на клиента",
|
||||||
|
"clientSecret": "Клиентска тайна",
|
||||||
|
"issuerUrl": "URL адрес на издателя",
|
||||||
|
"authUrl": "URL адрес за оторизация",
|
||||||
|
"tokenUrl": "URL адрес на токена",
|
||||||
|
"userIdentifier": "Път на потребителския идентификатор",
|
||||||
|
"displayName": "Път на показваното име",
|
||||||
|
"scopes": "Обхвати",
|
||||||
|
"userinfoUrl": "Замяна на URL адреса на потребителската информация",
|
||||||
|
"groupClaim": "Групово искане",
|
||||||
|
"groupClaimDesc": "Незадължително. Пътят на твърдението, който съдържа групите на потребителя. По подразбиране е „групи“, „роли“, „група“. Използвайте това за доставчици с персонализирано твърдение (напр. Zitadel).",
|
||||||
|
"allowedUsers": "Разрешени потребители",
|
||||||
|
"allowedUsersDesc": "По един имейл на ред. Оставете празно, за да разрешите всички.",
|
||||||
|
"adminGroup": "Административна група",
|
||||||
|
"adminGroupDesc": "Потребителите в тази група са с администраторски права. Оставете празно, за да деактивирате синхронизирането на групата.",
|
||||||
|
"caCert": "Персонализиран CA сертификат",
|
||||||
|
"caCertDesc": "Незадължително. PEM-кодиран CA сертификат за OIDC доставчици, използващи частен или самоподписан CA. Оставете празно, за да използвате хранилището за доверени сертификати на системата."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}} দিয়ে লগইন করুন",
|
||||||
|
"loading": "লোড হচ্ছে...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "নথি দেখুন",
|
||||||
|
"none": "কোনো SSO প্রদানকারী কনফিগার করা নেই।",
|
||||||
|
"add": "প্রদানকারী যোগ করুন",
|
||||||
|
"edit": "সম্পাদনা প্রদানকারী",
|
||||||
|
"delete": "প্রোভাইডার মুছুন",
|
||||||
|
"deleteConfirm": "এই প্রোভাইডারটি মুছে ফেললে সংশ্লিষ্ট ব্যবহারকারীরা লগইন করতে পারবেন না।",
|
||||||
|
"name": "প্রদর্শনের নাম",
|
||||||
|
"type": "প্রদানকারীর ধরণ",
|
||||||
|
"enabled": "সক্ষম করা হয়েছে",
|
||||||
|
"save": "সরবরাহকারীকে সংরক্ষণ করুন",
|
||||||
|
"saving": "সঞ্চয়...",
|
||||||
|
"saved": "সংরক্ষিত",
|
||||||
|
"saveFailed": "সংরক্ষণ করতে ব্যর্থ হয়েছে",
|
||||||
|
"deleted": "মুছে ফেলা হয়েছে",
|
||||||
|
"deleteFailed": "মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||||
|
"cancel": "বাতিল করুন"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ক্লায়েন্ট আইডি",
|
||||||
|
"clientSecret": "ক্লায়েন্ট গোপনীয়",
|
||||||
|
"issuerUrl": "ইস্যুকারী ইউআরএল",
|
||||||
|
"authUrl": "অনুমোদন ইউআরএল",
|
||||||
|
"tokenUrl": "টোকেন ইউআরএল",
|
||||||
|
"userIdentifier": "ব্যবহারকারী শনাক্তকারী পথ",
|
||||||
|
"displayName": "ডিসপ্লে নেম পাথ",
|
||||||
|
"scopes": "স্কোপ",
|
||||||
|
"userinfoUrl": "ব্যবহারকারীর তথ্য ইউআরএল ওভাররাইড করুন",
|
||||||
|
"groupClaim": "গোষ্ঠী দাবি",
|
||||||
|
"groupClaimDesc": "ঐচ্ছিক। এটি হলো সেই ক্লেইম পাথ যেখানে ব্যবহারকারীর গ্রুপগুলো থাকে। ডিফল্ট হিসেবে প্রথমে গ্রুপ, তারপর রোল, এবং সবশেষে গ্রুপ থাকে। কাস্টম ক্লেইম আছে এমন প্রোভাইডারদের (যেমন জিটাডেল) জন্য এটি ব্যবহার করুন।",
|
||||||
|
"allowedUsers": "অনুমোদিত ব্যবহারকারী",
|
||||||
|
"allowedUsersDesc": "প্রতি লাইনে একটি ইমেল। সবগুলো দেখানোর জন্য খালি রাখুন।",
|
||||||
|
"adminGroup": "অ্যাডমিন গ্রুপ",
|
||||||
|
"adminGroupDesc": "এই গ্রুপের ব্যবহারকারীদের অ্যাডমিন অ্যাক্সেস দেওয়া হয়েছে। গ্রুপ সিঙ্ক নিষ্ক্রিয় করতে এটি খালি রাখুন।",
|
||||||
|
"caCert": "কাস্টম সিএ সার্টিফিকেট",
|
||||||
|
"caCertDesc": "ঐচ্ছিক। প্রাইভেট বা সেলফ-সাইন্ড CA ব্যবহারকারী OIDC প্রোভাইডারদের জন্য PEM-এনকোডেড CA সার্টিফিকেট। সিস্টেম ট্রাস্ট স্টোর ব্যবহার করতে এটি খালি রাখুন।"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Inicia la sessió amb {{name}}",
|
||||||
|
"loading": "S'està carregant...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Veure documents",
|
||||||
|
"none": "No hi ha cap proveïdor d'SSO configurat.",
|
||||||
|
"add": "Afegeix un proveïdor",
|
||||||
|
"edit": "Edita el proveïdor",
|
||||||
|
"delete": "Suprimeix el proveïdor",
|
||||||
|
"deleteConfirm": "Voleu suprimir aquest proveïdor? Els usuaris associats no podran iniciar la sessió.",
|
||||||
|
"name": "Nom de visualització",
|
||||||
|
"type": "Tipus de proveïdor",
|
||||||
|
"enabled": "Activat",
|
||||||
|
"save": "Desa el proveïdor",
|
||||||
|
"saving": "Desant...",
|
||||||
|
"saved": "Desat",
|
||||||
|
"saveFailed": "No s'ha pogut desar",
|
||||||
|
"deleted": "Suprimit",
|
||||||
|
"deleteFailed": "No s'ha pogut suprimir",
|
||||||
|
"cancel": "Cancel·la"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID de client",
|
||||||
|
"clientSecret": "Secret del client",
|
||||||
|
"issuerUrl": "URL de l'emissor",
|
||||||
|
"authUrl": "URL d'autorització",
|
||||||
|
"tokenUrl": "URL del testimoni",
|
||||||
|
"userIdentifier": "Ruta d'identificació d'usuari",
|
||||||
|
"displayName": "Camí del nom de visualització",
|
||||||
|
"scopes": "Àmbits",
|
||||||
|
"userinfoUrl": "Substitueix l'URL d'informació de l'usuari",
|
||||||
|
"groupClaim": "Reclamació de grup",
|
||||||
|
"groupClaimDesc": "Opcional. La ruta de reclamació que conté els grups de l'usuari. Per defecte, s'utilitzen grups, rols i, a continuació, grup. Feu servir això per a proveïdors amb una reclamació personalitzada (per exemple, Zitadel).",
|
||||||
|
"allowedUsers": "Usuaris permesos",
|
||||||
|
"allowedUsersDesc": "Un correu electrònic per línia. Deixeu-ho en blanc per permetre-ho tot.",
|
||||||
|
"adminGroup": "Grup d'administradors",
|
||||||
|
"adminGroupDesc": "Els usuaris d'aquest grup tenen dret d'administrador. Deixeu-ho en blanc per desactivar la sincronització del grup.",
|
||||||
|
"caCert": "Certificat CA personalitzat",
|
||||||
|
"caCertDesc": "Opcional. Certificat CA codificat amb PEM per a proveïdors OIDC que utilitzen una CA privada o autosignada. Deixeu-ho en blanc per utilitzar el magatzem de confiança del sistema."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Přihlaste se pomocí {{name}}",
|
||||||
|
"loading": "Načítání...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Zobrazit dokumenty",
|
||||||
|
"none": "Nejsou nakonfigurováni žádní poskytovatelé jednotného přihlašování (SSO).",
|
||||||
|
"add": "Přidat poskytovatele",
|
||||||
|
"edit": "Upravit poskytovatele",
|
||||||
|
"delete": "Smazat poskytovatele",
|
||||||
|
"deleteConfirm": "Smazat tohoto poskytovatele? Přidružení uživatelé se nebudou moci přihlásit.",
|
||||||
|
"name": "Zobrazovaný název",
|
||||||
|
"type": "Typ poskytovatele",
|
||||||
|
"enabled": "Povoleno",
|
||||||
|
"save": "Uložit poskytovatele",
|
||||||
|
"saving": "Ukládání...",
|
||||||
|
"saved": "Uloženo",
|
||||||
|
"saveFailed": "Uložení se nezdařilo",
|
||||||
|
"deleted": "Smazáno",
|
||||||
|
"deleteFailed": "Nepodařilo se smazat",
|
||||||
|
"cancel": "Zrušit"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID klienta",
|
||||||
|
"clientSecret": "Tajemství klienta",
|
||||||
|
"issuerUrl": "URL vydavatele",
|
||||||
|
"authUrl": "Autorizační URL",
|
||||||
|
"tokenUrl": "URL tokenu",
|
||||||
|
"userIdentifier": "Cesta k identifikátoru uživatele",
|
||||||
|
"displayName": "Cesta k zobrazovanému názvu",
|
||||||
|
"scopes": "Rozsahy",
|
||||||
|
"userinfoUrl": "Přepsat URL s uživatelskými informacemi",
|
||||||
|
"groupClaim": "Skupinový nárok",
|
||||||
|
"groupClaimDesc": "Volitelné. Cesta k deklaraci identity, která obsahuje skupiny uživatele. Výchozí nastavení je skupiny, role a poté skupina. Použijte pro poskytovatele s vlastní deklarací identity (např. Zitadel).",
|
||||||
|
"allowedUsers": "Povolení uživatelé",
|
||||||
|
"allowedUsersDesc": "Jeden e-mail na řádek. Pro povolení všech ponechte prázdné.",
|
||||||
|
"adminGroup": "Skupina administrátorů",
|
||||||
|
"adminGroupDesc": "Uživatelé v této skupině mají oprávnění k administraci. Pro deaktivaci synchronizace skupiny ponechte pole prázdné.",
|
||||||
|
"caCert": "Vlastní certifikát certifikační autority",
|
||||||
|
"caCertDesc": "Volitelné. Certifikát certifikační autority s kódováním PEM pro poskytovatele OIDC používající soukromou nebo samopodepsanou certifikační autoritu. Pro použití systémového úložiště důvěryhodných certifikátů ponechte pole prázdné."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Log ind med {{name}}",
|
||||||
|
"loading": "Indlæser...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Se dokumenter",
|
||||||
|
"none": "Ingen SSO-udbydere er konfigureret.",
|
||||||
|
"add": "Tilføj udbyder",
|
||||||
|
"edit": "Rediger udbyder",
|
||||||
|
"delete": "Slet udbyder",
|
||||||
|
"deleteConfirm": "Vil du slette denne udbyder? Tilknyttede brugere vil ikke kunne logge ind.",
|
||||||
|
"name": "Vist navn",
|
||||||
|
"type": "Udbydertype",
|
||||||
|
"enabled": "Aktiveret",
|
||||||
|
"save": "Gem udbyder",
|
||||||
|
"saving": "Gemmer...",
|
||||||
|
"saved": "Gemt",
|
||||||
|
"saveFailed": "Kunne ikke gemme",
|
||||||
|
"deleted": "Slettet",
|
||||||
|
"deleteFailed": "Kunne ikke slette",
|
||||||
|
"cancel": "Ophæve"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Klient-ID",
|
||||||
|
"clientSecret": "Klienthemmelighed",
|
||||||
|
"issuerUrl": "Udsteders URL",
|
||||||
|
"authUrl": "Autorisations-URL",
|
||||||
|
"tokenUrl": "Token-URL",
|
||||||
|
"userIdentifier": "Brugeridentifikatorsti",
|
||||||
|
"displayName": "Sti til visningsnavn",
|
||||||
|
"scopes": "Omfang",
|
||||||
|
"userinfoUrl": "Tilsidesæt brugerinfo-URL",
|
||||||
|
"groupClaim": "Gruppekrav",
|
||||||
|
"groupClaimDesc": "Valgfrit. Kravsstien, der indeholder brugerens grupper. Standardindstillingen er grupper, roller og derefter gruppe. Brug dette til udbydere med et brugerdefineret krav (f.eks. Zitadel).",
|
||||||
|
"allowedUsers": "Tilladte brugere",
|
||||||
|
"allowedUsersDesc": "Én e-mail pr. linje. Lad feltet stå tomt for at tillade alle.",
|
||||||
|
"adminGroup": "Admingruppe",
|
||||||
|
"adminGroupDesc": "Brugere i denne gruppe har administratorrettigheder. Lad feltet stå tomt for at deaktivere gruppesynkronisering.",
|
||||||
|
"caCert": "Brugerdefineret CA-certifikat",
|
||||||
|
"caCertDesc": "Valgfrit. PEM-kodet CA-certifikat til OIDC-udbydere, der bruger en privat eller selvsigneret CA. Lad feltet stå tomt for at bruge systemets tillidslager."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Anmeldung mit {{name}}",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Dokumentation anzeigen",
|
||||||
|
"none": "Keine SSO-Anbieter konfiguriert.",
|
||||||
|
"add": "Anbieter hinzufügen",
|
||||||
|
"edit": "Anbieter bearbeiten",
|
||||||
|
"delete": "Anbieter löschen",
|
||||||
|
"deleteConfirm": "Diesen Anbieter löschen? Zugehörige Benutzer können sich nicht mehr anmelden.",
|
||||||
|
"name": "Anzeigename",
|
||||||
|
"type": "Anbietertyp",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"save": "Anbieter speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"saved": "Gespeichert",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
|
"deleted": "Gelöscht",
|
||||||
|
"deleteFailed": "Löschen fehlgeschlagen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Client-ID",
|
||||||
|
"clientSecret": "Client-Secret",
|
||||||
|
"issuerUrl": "Issuer-URL",
|
||||||
|
"authUrl": "Autorisierungs-URL",
|
||||||
|
"tokenUrl": "Token-URL",
|
||||||
|
"userIdentifier": "Benutzerkennungspfad",
|
||||||
|
"displayName": "Anzeigename-Pfad",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"userinfoUrl": "Userinfo-URL überschreiben",
|
||||||
|
"groupClaim": "Gruppen-Claim",
|
||||||
|
"groupClaimDesc": "Optional. Der Claim-Pfad, der die Gruppen des Benutzers enthält. Standardmäßig groups, roles, dann group. Verwenden Sie dies für Anbieter mit einem benutzerdefinierten Claim (z. B. Zitadel).",
|
||||||
|
"allowedUsers": "Zugelassene Benutzer",
|
||||||
|
"allowedUsersDesc": "Eine E-Mail pro Zeile. Leer lassen, um alle zuzulassen.",
|
||||||
|
"adminGroup": "Admin-Gruppe",
|
||||||
|
"adminGroupDesc": "Benutzer in dieser Gruppe erhalten Admin-Berechtigungen. Leer lassen, um die Gruppensynchronisierung zu deaktivieren.",
|
||||||
|
"caCert": "Benutzerdefiniertes CA-Zertifikat",
|
||||||
|
"caCertDesc": "Optional. PEM-kodiertes CA-Zertifikat für OIDC-Anbieter, die eine private oder selbstsignierte Zertifizierungsstelle verwenden. Leer lassen, um den System-Vertrauensspeicher zu verwenden."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Σύνδεση με {{name}}",
|
||||||
|
"loading": "Φόρτωση...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Προβολή εγγράφων",
|
||||||
|
"none": "Δεν έχουν ρυθμιστεί πάροχοι SSO.",
|
||||||
|
"add": "Προσθήκη παρόχου",
|
||||||
|
"edit": "Επεξεργασία παρόχου",
|
||||||
|
"delete": "Διαγραφή παρόχου",
|
||||||
|
"deleteConfirm": "Διαγραφή αυτού του παρόχου; Οι συνδεδεμένοι χρήστες δεν θα μπορούν να συνδεθούν.",
|
||||||
|
"name": "Εμφανιζόμενο όνομα",
|
||||||
|
"type": "Τύπος παρόχου",
|
||||||
|
"enabled": "Ενεργοποιημένο",
|
||||||
|
"save": "Αποθήκευση παρόχου",
|
||||||
|
"saving": "Οικονομία...",
|
||||||
|
"saved": "Αποθηκεύτηκε",
|
||||||
|
"saveFailed": "Η αποθήκευση απέτυχε",
|
||||||
|
"deleted": "Διαγράφηκε",
|
||||||
|
"deleteFailed": "Η διαγραφή απέτυχε",
|
||||||
|
"cancel": "Ματαίωση"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Αναγνωριστικό πελάτη",
|
||||||
|
"clientSecret": "Μυστικό Πελάτη",
|
||||||
|
"issuerUrl": "URL εκδότη",
|
||||||
|
"authUrl": "URL εξουσιοδότησης",
|
||||||
|
"tokenUrl": "URL διακριτικού",
|
||||||
|
"userIdentifier": "Διαδρομή αναγνωριστικού χρήστη",
|
||||||
|
"displayName": "Διαδρομή ονόματος εμφάνισης",
|
||||||
|
"scopes": "Πεδία",
|
||||||
|
"userinfoUrl": "Παράκαμψη URL πληροφοριών χρήστη",
|
||||||
|
"groupClaim": "Ομαδική αξίωση",
|
||||||
|
"groupClaimDesc": "Προαιρετικό. Η διαδρομή αξίωσης που περιέχει τις ομάδες του χρήστη. Η προεπιλεγμένη τιμή είναι ομάδες, ρόλοι και στη συνέχεια ομάδα. Χρησιμοποιήστε αυτήν τη διαδρομή για παρόχους με προσαρμοσμένη αξίωση (π.χ. Zitadel).",
|
||||||
|
"allowedUsers": "Επιτρεπόμενοι χρήστες",
|
||||||
|
"allowedUsersDesc": "Ένα email ανά γραμμή. Αφήστε το κενό για να επιτρέπονται όλα.",
|
||||||
|
"adminGroup": "Ομάδα Διαχειριστών",
|
||||||
|
"adminGroupDesc": "Οι χρήστες σε αυτήν την ομάδα έχουν παραχωρηθεί ως διαχειριστές. Αφήστε το κενό για να απενεργοποιήσετε τον συγχρονισμό ομάδας.",
|
||||||
|
"caCert": "Προσαρμοσμένο Πιστοποιητικό CA",
|
||||||
|
"caCertDesc": "Προαιρετικό. Πιστοποιητικό CA με κωδικοποίηση PEM για παρόχους OIDC που χρησιμοποιούν ιδιωτική ή αυτο-υπογεγραμμένη αρχή έκδοσης πιστοποιητικών. Αφήστε το κενό για να χρησιμοποιήσετε τον χώρο αποθήκευσης αξιοπιστίας του συστήματος."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Iniciar sesión con {{name}}",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Ver documentación",
|
||||||
|
"none": "No hay proveedores SSO configurados.",
|
||||||
|
"add": "Añadir proveedor",
|
||||||
|
"edit": "Editar proveedor",
|
||||||
|
"delete": "Eliminar proveedor",
|
||||||
|
"deleteConfirm": "¿Eliminar este proveedor? Los usuarios asociados no podrán iniciar sesión.",
|
||||||
|
"name": "Nombre visible",
|
||||||
|
"type": "Tipo de proveedor",
|
||||||
|
"enabled": "Activado",
|
||||||
|
"save": "Guardar proveedor",
|
||||||
|
"saving": "Guardando...",
|
||||||
|
"saved": "Guardado",
|
||||||
|
"saveFailed": "Error al guardar",
|
||||||
|
"deleted": "Eliminado",
|
||||||
|
"deleteFailed": "Error al eliminar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID de cliente",
|
||||||
|
"clientSecret": "Secreto de cliente",
|
||||||
|
"issuerUrl": "URL del emisor",
|
||||||
|
"authUrl": "URL de autorización",
|
||||||
|
"tokenUrl": "URL del token",
|
||||||
|
"userIdentifier": "Ruta del identificador de usuario",
|
||||||
|
"displayName": "Ruta del nombre para mostrar",
|
||||||
|
"scopes": "Ámbitos",
|
||||||
|
"userinfoUrl": "Sobrescribir URL de Userinfo",
|
||||||
|
"groupClaim": "Claim de grupo",
|
||||||
|
"groupClaimDesc": "Opcional. La ruta del claim que contiene los grupos del usuario. Por defecto, se usan 'groups', 'roles' y luego 'group'. Úselo para proveedores con un claim personalizado (p. ej. Zitadel).",
|
||||||
|
"allowedUsers": "Usuarios permitidos",
|
||||||
|
"allowedUsersDesc": "Un correo electrónico por línea. Déjelo vacío para permitir todos.",
|
||||||
|
"adminGroup": "Grupo de administradores",
|
||||||
|
"adminGroupDesc": "Los usuarios de este grupo reciben permisos de administrador. Déjelo vacío para desactivar la sincronización de grupos.",
|
||||||
|
"caCert": "Certificado CA personalizado",
|
||||||
|
"caCertDesc": "Opcional. Certificado CA codificado en PEM para proveedores OIDC que utilicen una CA privada o autofirmada. Déjelo vacío para usar el almacén de confianza del sistema."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Kirjaudu sisään tunnuksella {{name}}",
|
||||||
|
"loading": "Ladataan...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Näytä dokumentit",
|
||||||
|
"none": "SSO-palveluntarjoajia ei ole määritetty.",
|
||||||
|
"add": "Lisää palveluntarjoaja",
|
||||||
|
"edit": "Muokkaa palveluntarjoajaa",
|
||||||
|
"delete": "Poista palveluntarjoaja",
|
||||||
|
"deleteConfirm": "Poistetaanko tämä palveluntarjoaja? Liitännäiset käyttäjät eivät voi kirjautua sisään.",
|
||||||
|
"name": "Näyttönimi",
|
||||||
|
"type": "Palveluntarjoajan tyyppi",
|
||||||
|
"enabled": "Käytössä",
|
||||||
|
"save": "Tallenna palveluntarjoaja",
|
||||||
|
"saving": "Tallennetaan...",
|
||||||
|
"saved": "Tallennettu",
|
||||||
|
"saveFailed": "Tallennus epäonnistui",
|
||||||
|
"deleted": "Poistettu",
|
||||||
|
"deleteFailed": "Poistaminen epäonnistui",
|
||||||
|
"cancel": "Peruuttaa"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Asiakastunnus",
|
||||||
|
"clientSecret": "Asiakkaan salaisuus",
|
||||||
|
"issuerUrl": "Liikkeeseenlaskijan URL-osoite",
|
||||||
|
"authUrl": "Valtuutus-URL-osoite",
|
||||||
|
"tokenUrl": "Tunnuksen URL-osoite",
|
||||||
|
"userIdentifier": "Käyttäjätunnisteen polku",
|
||||||
|
"displayName": "Näyttönimen polku",
|
||||||
|
"scopes": "Laajuusalueet",
|
||||||
|
"userinfoUrl": "Käyttäjätietojen URL-osoitteen ohitus",
|
||||||
|
"groupClaim": "Ryhmävaatimus",
|
||||||
|
"groupClaimDesc": "Valinnainen. Vaatimuspolku, joka sisältää käyttäjän ryhmät. Oletusarvo on ryhmät, roolit ja lopuksi ryhmä. Käytä tätä palveluntarjoajille, joilla on mukautettu vaatimus (esim. Zitadel).",
|
||||||
|
"allowedUsers": "Sallitut käyttäjät",
|
||||||
|
"allowedUsersDesc": "Yksi sähköpostiosoite riviä kohden. Jätä tyhjäksi salliaksesi kaikki.",
|
||||||
|
"adminGroup": "Hallintaryhmä",
|
||||||
|
"adminGroupDesc": "Tämän ryhmän käyttäjille on myönnetty järjestelmänvalvojan oikeudet. Jätä tyhjäksi, jos haluat poistaa ryhmän synkronoinnin käytöstä.",
|
||||||
|
"caCert": "Mukautettu CA-varmenne",
|
||||||
|
"caCertDesc": "Valinnainen. PEM-koodattu CA-varmenne OIDC-palveluntarjoajille, jotka käyttävät yksityistä tai itse allekirjoitettua CA:ta. Jätä tyhjäksi, jos haluat käyttää järjestelmän luotettavuussäilöä."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Se connecter avec {{name}}",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Documentation",
|
||||||
|
"none": "Aucun fournisseur SSO configuré.",
|
||||||
|
"add": "Ajouter un fournisseur",
|
||||||
|
"edit": "Modifier le fournisseur",
|
||||||
|
"delete": "Supprimer le fournisseur",
|
||||||
|
"deleteConfirm": "Supprimer ce fournisseur ? Les utilisateurs associés ne pourront plus se connecter.",
|
||||||
|
"name": "Nom d’affichage",
|
||||||
|
"type": "Type de fournisseur",
|
||||||
|
"enabled": "Activé",
|
||||||
|
"save": "Enregistrer le fournisseur",
|
||||||
|
"saving": "Enregistrement…",
|
||||||
|
"saved": "Enregistré",
|
||||||
|
"saveFailed": "Échec de l'enregistrement",
|
||||||
|
"deleted": "Supprimé",
|
||||||
|
"deleteFailed": "Échec de la suppression",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID client",
|
||||||
|
"clientSecret": "Secret client",
|
||||||
|
"issuerUrl": "URL de l'émetteur",
|
||||||
|
"authUrl": "URL d'autorisation",
|
||||||
|
"tokenUrl": "URL du jeton",
|
||||||
|
"userIdentifier": "Chemin de l'identifiant utilisateur",
|
||||||
|
"displayName": "Chemin du nom affiché",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"userinfoUrl": "URL Userinfo personnalisée",
|
||||||
|
"groupClaim": "Revendication de groupe",
|
||||||
|
"groupClaimDesc": "Facultatif. Chemin de la revendication contenant les groupes de l'utilisateur. Par défaut : groups, roles, puis group. Utilisez ce champ pour les fournisseurs avec une revendication personnalisée (ex. Zitadel).",
|
||||||
|
"allowedUsers": "Utilisateurs autorisés",
|
||||||
|
"allowedUsersDesc": "Une adresse e-mail par ligne. Laissez vide pour autoriser tout le monde.",
|
||||||
|
"adminGroup": "Groupe administrateur",
|
||||||
|
"adminGroupDesc": "Les utilisateurs de ce groupe ont les droits administrateur. Laissez vide pour désactiver la synchronisation de groupe.",
|
||||||
|
"caCert": "Certificat CA personnalisé",
|
||||||
|
"caCertDesc": "Facultatif. Certificat CA au format PEM pour les fournisseurs OIDC utilisant une CA privée ou auto-signée. Laissez vide pour utiliser le magasin de confiance du système."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "התחברות באמצעות {{name}}",
|
||||||
|
"loading": "טְעִינָה...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "הצג מסמכים",
|
||||||
|
"none": "לא הוגדרו ספקי SSO.",
|
||||||
|
"add": "הוסף ספק",
|
||||||
|
"edit": "עריכת ספק",
|
||||||
|
"delete": "מחיקת ספק",
|
||||||
|
"deleteConfirm": "למחוק ספק זה? משתמשים משויכים לא יוכלו להתחבר.",
|
||||||
|
"name": "שם תצוגה",
|
||||||
|
"type": "סוג ספק",
|
||||||
|
"enabled": "מופעל",
|
||||||
|
"save": "שמור ספק",
|
||||||
|
"saving": "חִסָכוֹן...",
|
||||||
|
"saved": "נשמר",
|
||||||
|
"saveFailed": "השמירה נכשלה",
|
||||||
|
"deleted": "נמחק",
|
||||||
|
"deleteFailed": "המחיקה נכשלה",
|
||||||
|
"cancel": "לְבַטֵל"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "מזהה לקוח",
|
||||||
|
"clientSecret": "סוד הלקוח",
|
||||||
|
"issuerUrl": "כתובת URL של המנפיק",
|
||||||
|
"authUrl": "כתובת אתר להרשאה",
|
||||||
|
"tokenUrl": "כתובת אתר של אסימון",
|
||||||
|
"userIdentifier": "נתיב מזהה המשתמש",
|
||||||
|
"displayName": "נתיב שם התצוגה",
|
||||||
|
"scopes": "טווחים",
|
||||||
|
"userinfoUrl": "עקיפת כתובת URL של פרטי משתמש",
|
||||||
|
"groupClaim": "תביעה קבוצתית",
|
||||||
|
"groupClaimDesc": "אופציונלי. נתיב התביעה המכיל את קבוצות המשתמש. ברירת המחדל היא קבוצות, תפקידים ולאחר מכן קבוצה. השתמש באפשרות זו עבור ספקים עם תביעה מותאמת אישית (למשל Zitadel).",
|
||||||
|
"allowedUsers": "משתמשים מורשים",
|
||||||
|
"allowedUsersDesc": "אימייל אחד בכל שורה. יש להשאיר ריק כדי לאפשר את כולם.",
|
||||||
|
"adminGroup": "קבוצת ניהול",
|
||||||
|
"adminGroupDesc": "למשתמשים בקבוצה זו יש הרשאה לניהול. יש להשאיר ריק כדי להשבית את סנכרון הקבוצה.",
|
||||||
|
"caCert": "תעודת CA מותאמת אישית",
|
||||||
|
"caCertDesc": "אופציונלי. אישור CA מקודד PEM עבור ספקי OIDC המשתמשים באישור CA פרטי או חתום עצמית. השאר ריק כדי להשתמש במאגר האמון של המערכת."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}} से लॉगिन करें",
|
||||||
|
"loading": "लोड हो रहा है...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "दस्तावेज़ देखें",
|
||||||
|
"none": "कोई SSO प्रदाता कॉन्फ़िगर नहीं है।",
|
||||||
|
"add": "प्रदाता जोड़ें",
|
||||||
|
"edit": "प्रदाता संपादित करें",
|
||||||
|
"delete": "प्रदाता हटाएँ",
|
||||||
|
"deleteConfirm": "इस प्रदाता को हटाएँ? इससे जुड़े उपयोगकर्ता लॉगिन नहीं कर पाएंगे।",
|
||||||
|
"name": "प्रदर्शन नाम",
|
||||||
|
"type": "प्रदाता प्रकार",
|
||||||
|
"enabled": "सक्षम",
|
||||||
|
"save": "प्रदाता सहेजें",
|
||||||
|
"saving": "सहेजा जा रहा है...",
|
||||||
|
"saved": "सहेजा गया",
|
||||||
|
"saveFailed": "सहेजने में विफल",
|
||||||
|
"deleted": "हटाया गया",
|
||||||
|
"deleteFailed": "हटाने में विफल",
|
||||||
|
"cancel": "रद्द करें"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "क्लाइंट आईडी",
|
||||||
|
"clientSecret": "क्लाइंट सीक्रेट",
|
||||||
|
"issuerUrl": "जारीकर्ता URL",
|
||||||
|
"authUrl": "प्राधिकरण URL",
|
||||||
|
"tokenUrl": "टोकन URL",
|
||||||
|
"userIdentifier": "उपयोगकर्ता पहचानकर्ता पथ",
|
||||||
|
"displayName": "प्रदर्शन नाम पथ",
|
||||||
|
"scopes": "स्कोप",
|
||||||
|
"userinfoUrl": "Userinfo URL ओवरराइड करें",
|
||||||
|
"groupClaim": "समूह दावा",
|
||||||
|
"groupClaimDesc": "वैकल्पिक। वह दावा पथ जिसमें उपयोगकर्ता के समूह शामिल हैं। डिफ़ॉल्ट रूप से groups, roles, फिर group होता है। कस्टम दावे वाले प्रदाताओं के लिए इसका उपयोग करें (जैसे Zitadel)।",
|
||||||
|
"allowedUsers": "अनुमत उपयोगकर्ता",
|
||||||
|
"allowedUsersDesc": "प्रति पंक्ति एक ईमेल। सभी को अनुमति देने के लिए खाली छोड़ें।",
|
||||||
|
"adminGroup": "व्यवस्थापक समूह",
|
||||||
|
"adminGroupDesc": "इस समूह के उपयोगकर्ताओं को व्यवस्थापक अधिकार दिए जाते हैं। समूह सिंक अक्षम करने के लिए खाली छोड़ें।",
|
||||||
|
"caCert": "कस्टम CA प्रमाणपत्र",
|
||||||
|
"caCertDesc": "वैकल्पिक। निजी या स्व-हस्ताक्षरित CA का उपयोग करने वाले OIDC प्रदाताओं के लिए PEM-एन्कोडेड CA प्रमाणपत्र। सिस्टम ट्रस्ट स्टोर का उपयोग करने के लिए खाली छोड़ें।"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Bejelentkezés {{name}} használatával",
|
||||||
|
"loading": "Terhelés...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Dokumentumok megtekintése",
|
||||||
|
"none": "Nincsenek konfigurálva SSO-szolgáltatók.",
|
||||||
|
"add": "Szolgáltató hozzáadása",
|
||||||
|
"edit": "Szolgáltató szerkesztése",
|
||||||
|
"delete": "Szolgáltató törlése",
|
||||||
|
"deleteConfirm": "Törli ezt a szolgáltatót? A társított felhasználók nem fognak tudni bejelentkezni.",
|
||||||
|
"name": "Megjelenített név",
|
||||||
|
"type": "Szolgáltató típusa",
|
||||||
|
"enabled": "Engedélyezve",
|
||||||
|
"save": "Szolgáltató mentése",
|
||||||
|
"saving": "Megtakarítás...",
|
||||||
|
"saved": "Mentett",
|
||||||
|
"saveFailed": "Nem sikerült menteni",
|
||||||
|
"deleted": "Törölt",
|
||||||
|
"deleteFailed": "Nem sikerült törölni",
|
||||||
|
"cancel": "Mégsem"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Ügyfél-azonosító",
|
||||||
|
"clientSecret": "Ügyféltitkos kód",
|
||||||
|
"issuerUrl": "Kibocsátó URL-címe",
|
||||||
|
"authUrl": "Engedélyezési URL",
|
||||||
|
"tokenUrl": "Token URL",
|
||||||
|
"userIdentifier": "Felhasználói azonosító útvonala",
|
||||||
|
"displayName": "Megjelenített név útvonala",
|
||||||
|
"scopes": "Hatókör",
|
||||||
|
"userinfoUrl": "Felhasználói adatok URL-címének felülbírálása",
|
||||||
|
"groupClaim": "Csoportos követelés",
|
||||||
|
"groupClaimDesc": "Opcionális. A felhasználó csoportjait tartalmazó igénylési útvonal. Alapértelmezés szerint csoportok, szerepkörök, majd csoport. Ezt egyéni igényléssel rendelkező szolgáltatóknál használja (pl. Zitadel).",
|
||||||
|
"allowedUsers": "Engedélyezett felhasználók",
|
||||||
|
"allowedUsersDesc": "Egy e-mail cím soronként. Hagyja üresen, ha az összeset engedélyezi.",
|
||||||
|
"adminGroup": "Adminisztrátori csoport",
|
||||||
|
"adminGroupDesc": "A csoport felhasználói adminisztrátori jogosultsággal rendelkeznek. Hagyja üresen a csoport szinkronizálásának letiltásához.",
|
||||||
|
"caCert": "Egyéni CA tanúsítvány",
|
||||||
|
"caCertDesc": "Opcionális. PEM-kódolású CA-tanúsítvány OIDC-szolgáltatók számára, privát vagy önaláírt CA-t használva. Hagyja üresen a rendszer megbízható tárolójának használatához."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Masuk dengan {{name}}",
|
||||||
|
"loading": "Memuat...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Lihat dokumen",
|
||||||
|
"none": "Tidak ada penyedia SSO yang dikonfigurasi.",
|
||||||
|
"add": "Tambahkan Penyedia",
|
||||||
|
"edit": "Edit Penyedia",
|
||||||
|
"delete": "Hapus Penyedia",
|
||||||
|
"deleteConfirm": "Hapus penyedia ini? Pengguna terkait tidak akan dapat masuk.",
|
||||||
|
"name": "Nama Tampilan",
|
||||||
|
"type": "Jenis Penyedia",
|
||||||
|
"enabled": "Diaktifkan",
|
||||||
|
"save": "Simpan Penyedia",
|
||||||
|
"saving": "Penghematan...",
|
||||||
|
"saved": "Tersimpan",
|
||||||
|
"saveFailed": "Gagal menyimpan",
|
||||||
|
"deleted": "Dihapus",
|
||||||
|
"deleteFailed": "Gagal menghapus",
|
||||||
|
"cancel": "Membatalkan"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID Klien",
|
||||||
|
"clientSecret": "Rahasia Klien",
|
||||||
|
"issuerUrl": "URL Penerbit",
|
||||||
|
"authUrl": "URL Otorisasi",
|
||||||
|
"tokenUrl": "URL Token",
|
||||||
|
"userIdentifier": "Jalur Pengidentifikasi Pengguna",
|
||||||
|
"displayName": "Jalur Nama Tampilan",
|
||||||
|
"scopes": "Lingkup",
|
||||||
|
"userinfoUrl": "Ganti URL Informasi Pengguna",
|
||||||
|
"groupClaim": "Klaim Kelompok",
|
||||||
|
"groupClaimDesc": "Opsional. Jalur klaim yang berisi grup pengguna. Secara default berupa grup, peran, lalu grup. Gunakan ini untuk penyedia dengan klaim khusus (misalnya Zitadel).",
|
||||||
|
"allowedUsers": "Pengguna yang Diizinkan",
|
||||||
|
"allowedUsersDesc": "Satu email per baris. Biarkan kosong untuk mengizinkan semua email.",
|
||||||
|
"adminGroup": "Grup Admin",
|
||||||
|
"adminGroupDesc": "Pengguna dalam grup ini diberikan hak admin. Biarkan kosong untuk menonaktifkan sinkronisasi grup.",
|
||||||
|
"caCert": "Sertifikat CA Kustom",
|
||||||
|
"caCertDesc": "Opsional. Sertifikat CA berkode PEM untuk penyedia OIDC yang menggunakan CA pribadi atau yang ditandatangani sendiri. Biarkan kosong untuk menggunakan penyimpanan kepercayaan sistem."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Accedi con {{name}}",
|
||||||
|
"loading": "Caricamento in corso...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Visualizza documentazione",
|
||||||
|
"none": "Nessun provider SSO configurato.",
|
||||||
|
"add": "Aggiungi provider",
|
||||||
|
"edit": "Modifica provider",
|
||||||
|
"delete": "Elimina provider",
|
||||||
|
"deleteConfirm": "Eliminare questo provider? Gli utenti associati non potranno più accedere.",
|
||||||
|
"name": "Nome visualizzato",
|
||||||
|
"type": "Tipo provider",
|
||||||
|
"enabled": "Abilitato",
|
||||||
|
"save": "Salva provider",
|
||||||
|
"saving": "Salvataggio in corso...",
|
||||||
|
"saved": "Salvato",
|
||||||
|
"saveFailed": "Salvataggio non riuscito",
|
||||||
|
"deleted": "Eliminato",
|
||||||
|
"deleteFailed": "Eliminazione non riuscita",
|
||||||
|
"cancel": "Annulla"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID client",
|
||||||
|
"clientSecret": "Segreto client",
|
||||||
|
"issuerUrl": "URL dell'emittente",
|
||||||
|
"authUrl": "URL di autorizzazione",
|
||||||
|
"tokenUrl": "URL token",
|
||||||
|
"userIdentifier": "Percorso identificatore utente",
|
||||||
|
"displayName": "Percorso nome visualizzato",
|
||||||
|
"scopes": "Ambiti",
|
||||||
|
"userinfoUrl": "Sovrascrivi URL Userinfo",
|
||||||
|
"groupClaim": "Attributo gruppo",
|
||||||
|
"groupClaimDesc": "Opzionale. Il percorso dell'attributo contenente i gruppi dell'utente. Il valore predefinito è groups, roles, quindi group. Da utilizzare per provider con un attributo personalizzato (es. Zitadel).",
|
||||||
|
"allowedUsers": "Utenti consentiti",
|
||||||
|
"allowedUsersDesc": "Un'email per riga. Lascia vuoto per consentire a tutti.",
|
||||||
|
"adminGroup": "Gruppo amministratori",
|
||||||
|
"adminGroupDesc": "Gli utenti di questo gruppo ottengono i diritti di amministratore. Lascia vuoto per disabilitare la sincronizzazione dei gruppi.",
|
||||||
|
"caCert": "Certificato CA personalizzato",
|
||||||
|
"caCertDesc": "Opzionale. Certificato CA in formato PEM per provider OIDC che utilizzano una CA privata o autofirmata. Lascia vuoto per usare l'archivio dei certificati attendibili di sistema."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}}でログイン",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "ドキュメントを表示",
|
||||||
|
"none": "SSOプロバイダーが設定されていません。",
|
||||||
|
"add": "追加",
|
||||||
|
"edit": "編集",
|
||||||
|
"delete": "削除",
|
||||||
|
"deleteConfirm": "このプロバイダーを削除しますか?関連付けられているユーザーはログインできなくなります。",
|
||||||
|
"name": "表示名",
|
||||||
|
"type": "プロバイダータイプ",
|
||||||
|
"enabled": "有効",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "保存しました",
|
||||||
|
"saveFailed": "保存に失敗しました",
|
||||||
|
"deleted": "削除しました",
|
||||||
|
"deleteFailed": "削除に失敗しました",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "クライアントID",
|
||||||
|
"clientSecret": "クライアントシークレット",
|
||||||
|
"issuerUrl": "発行者URL",
|
||||||
|
"authUrl": "認証URL",
|
||||||
|
"tokenUrl": "トークンURL",
|
||||||
|
"userIdentifier": "ユーザー識別子パス",
|
||||||
|
"displayName": "表示名パス",
|
||||||
|
"scopes": "スコープ",
|
||||||
|
"userinfoUrl": "Userinfo URLの上書き",
|
||||||
|
"groupClaim": "グループクレーム",
|
||||||
|
"groupClaimDesc": "オプション。ユーザーのグループを含むクレームパス。デフォルトは groups, roles, group の順。カスタムクレームを使うプロバイダー用 (例: Zitadel)。",
|
||||||
|
"allowedUsers": "許可ユーザー",
|
||||||
|
"allowedUsersDesc": "1行に1つのメールアドレス。空の場合は全員許可。",
|
||||||
|
"adminGroup": "管理者グループ",
|
||||||
|
"adminGroupDesc": "このグループのユーザーは管理者権限が付与されます。空にするとグループ同期は無効です。",
|
||||||
|
"caCert": "カスタムCA証明書",
|
||||||
|
"caCertDesc": "オプション。プライベートまたは自己署名CAを使用するOIDCプロバイダー用のPEMエンコードCA証明書。空の場合はシステムの信頼ストアを使用。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "{{name}}으로 로그인",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "문서 보기",
|
||||||
|
"none": "설정된 SSO 제공자가 없습니다.",
|
||||||
|
"add": "제공자 추가",
|
||||||
|
"edit": "제공자 편집",
|
||||||
|
"delete": "제공자 삭제",
|
||||||
|
"deleteConfirm": "이 제공자를 삭제하시겠습니까? 연결된 사용자는 로그인할 수 없습니다.",
|
||||||
|
"name": "표시 이름",
|
||||||
|
"type": "제공자 유형",
|
||||||
|
"enabled": "사용",
|
||||||
|
"save": "제공자 저장",
|
||||||
|
"saving": "저장 중...",
|
||||||
|
"saved": "저장됨",
|
||||||
|
"saveFailed": "저장 실패",
|
||||||
|
"deleted": "삭제됨",
|
||||||
|
"deleteFailed": "삭제 실패",
|
||||||
|
"cancel": "취소"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "클라이언트 ID",
|
||||||
|
"clientSecret": "클라이언트 시크릿",
|
||||||
|
"issuerUrl": "Issuer URL",
|
||||||
|
"authUrl": "인증 URL",
|
||||||
|
"tokenUrl": "토큰 URL",
|
||||||
|
"userIdentifier": "사용자 식별자 경로",
|
||||||
|
"displayName": "표시 이름 경로",
|
||||||
|
"scopes": "스코프",
|
||||||
|
"userinfoUrl": "Userinfo URL 재정의",
|
||||||
|
"groupClaim": "그룹 클레임",
|
||||||
|
"groupClaimDesc": "선택 사항입니다. 사용자 그룹이 포함된 클레임 경로입니다. 기본값은 groups, roles, group 순서로 검색됩니다. 사용자 정의 클레임이 있는 제공자(예: Zitadel)의 경우 이 값을 사용하세요.",
|
||||||
|
"allowedUsers": "허용된 사용자",
|
||||||
|
"allowedUsersDesc": "한 줄에 이메일 하나씩 입력하세요. 비워두면 모두 허용됩니다.",
|
||||||
|
"adminGroup": "관리자 그룹",
|
||||||
|
"adminGroupDesc": "이 그룹의 사용자에게 관리자 권한이 부여됩니다. 비워두면 그룹 동기화가 비활성화됩니다.",
|
||||||
|
"caCert": "사용자 정의 CA 인증서",
|
||||||
|
"caCertDesc": "선택 사항입니다. 개인 또는 자체 서명 CA를 사용하는 OIDC 제공자를 위한 PEM 인코딩 CA 인증서입니다. 비워두면 시스템 신뢰 저장소가 사용됩니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Inloggen met {{name}}",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Documenten bekijken",
|
||||||
|
"none": "Er zijn geen SSO-providers geconfigureerd.",
|
||||||
|
"add": "Aanbieder toevoegen",
|
||||||
|
"edit": "Bewerk provider",
|
||||||
|
"delete": "Provider verwijderen",
|
||||||
|
"deleteConfirm": "Deze provider verwijderen? Gekoppelde gebruikers kunnen dan niet meer inloggen.",
|
||||||
|
"name": "Weergavenaam",
|
||||||
|
"type": "Aanbiedertype",
|
||||||
|
"enabled": "Ingeschakeld",
|
||||||
|
"save": "Aanbieder opslaan",
|
||||||
|
"saving": "Besparing...",
|
||||||
|
"saved": "Opgeslagen",
|
||||||
|
"saveFailed": "Opslaan mislukt",
|
||||||
|
"deleted": "Verwijderd",
|
||||||
|
"deleteFailed": "Verwijderen mislukt",
|
||||||
|
"cancel": "Annuleren"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Klant-ID",
|
||||||
|
"clientSecret": "Klantgeheim",
|
||||||
|
"issuerUrl": "URL van de uitgever",
|
||||||
|
"authUrl": "Autorisatie-URL",
|
||||||
|
"tokenUrl": "Token-URL",
|
||||||
|
"userIdentifier": "Gebruikersidentificatiepad",
|
||||||
|
"displayName": "Weergavenaam Pad",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"userinfoUrl": "Gebruikersinfo-URL overschrijven",
|
||||||
|
"groupClaim": "Groepsclaim",
|
||||||
|
"groupClaimDesc": "Optioneel. Het pad naar de claim dat de groepen van de gebruiker bevat. Standaard is dit: groepen, rollen, vervolgens groep. Gebruik dit voor providers met een aangepaste claim (bijv. Zitadel).",
|
||||||
|
"allowedUsers": "Toegestane gebruikers",
|
||||||
|
"allowedUsersDesc": "Eén e-mailadres per regel. Laat dit veld leeg om alle e-mailadressen toe te staan.",
|
||||||
|
"adminGroup": "Beheerdersgroep",
|
||||||
|
"adminGroupDesc": "Gebruikers in deze groep hebben beheerdersrechten. Laat dit veld leeg om groepssynchronisatie uit te schakelen.",
|
||||||
|
"caCert": "Aangepast CA-certificaat",
|
||||||
|
"caCertDesc": "Optioneel. PEM-gecodeerd CA-certificaat voor OIDC-providers die gebruikmaken van een privé- of zelfondertekende CA. Laat dit veld leeg om het systeemvertrouwensarchief te gebruiken."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Logg inn med {{name}}",
|
||||||
|
"loading": "Laster inn...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Vis dokumenter",
|
||||||
|
"none": "Ingen SSO-leverandører er konfigurert.",
|
||||||
|
"add": "Legg til leverandør",
|
||||||
|
"edit": "Rediger leverandør",
|
||||||
|
"delete": "Slett leverandør",
|
||||||
|
"deleteConfirm": "Vil du slette denne leverandøren? Tilknyttede brukere vil ikke kunne logge inn.",
|
||||||
|
"name": "Visningsnavn",
|
||||||
|
"type": "Leverandørtype",
|
||||||
|
"enabled": "Aktivert",
|
||||||
|
"save": "Lagre leverandør",
|
||||||
|
"saving": "Lagrer...",
|
||||||
|
"saved": "Lagret",
|
||||||
|
"saveFailed": "Kunne ikke lagre",
|
||||||
|
"deleted": "Slettet",
|
||||||
|
"deleteFailed": "Kunne ikke slette",
|
||||||
|
"cancel": "Kansellere"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Klient-ID",
|
||||||
|
"clientSecret": "Klienthemmelighet",
|
||||||
|
"issuerUrl": "Utsteder-URL",
|
||||||
|
"authUrl": "Autorisasjons-URL",
|
||||||
|
"tokenUrl": "Token-URL",
|
||||||
|
"userIdentifier": "Brukeridentifikatorsti",
|
||||||
|
"displayName": "Sti til visningsnavn",
|
||||||
|
"scopes": "Omfang",
|
||||||
|
"userinfoUrl": "Overstyr brukerinfo-URL",
|
||||||
|
"groupClaim": "Gruppekrav",
|
||||||
|
"groupClaimDesc": "Valgfritt. Kravbanen som inneholder brukerens grupper. Standardinnstillingen er grupper, roller og deretter gruppe. Bruk dette for leverandører med et tilpasset krav (f.eks. Zitadel).",
|
||||||
|
"allowedUsers": "Tillatte brukere",
|
||||||
|
"allowedUsersDesc": "Én e-post per linje. La stå tomt for å tillate alle.",
|
||||||
|
"adminGroup": "Admingruppe",
|
||||||
|
"adminGroupDesc": "Brukere i denne gruppen har administratorrettigheter. La feltet stå tomt for å deaktivere gruppesynkronisering.",
|
||||||
|
"caCert": "Tilpasset CA-sertifikat",
|
||||||
|
"caCertDesc": "Valgfritt. PEM-kodet CA-sertifikat for OIDC-leverandører som bruker en privat eller selvsignert CA. La stå tomt for å bruke systemets klareringslager."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Zaloguj się za pomocą {{name}}",
|
||||||
|
"loading": "Ładowanie...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Zobacz dokumentację",
|
||||||
|
"none": "Nie skonfigurowano dostawców SSO.",
|
||||||
|
"add": "Dodaj dostawcę",
|
||||||
|
"edit": "Edytuj dostawcę",
|
||||||
|
"delete": "Usuń dostawcę",
|
||||||
|
"deleteConfirm": "Usunąć tego dostawcę? Powiązani użytkownicy nie będą mogli się zalogować.",
|
||||||
|
"name": "Nazwa wyświetlana",
|
||||||
|
"type": "Typ dostawcy",
|
||||||
|
"enabled": "Włączone",
|
||||||
|
"save": "Zapisz dostawcę",
|
||||||
|
"saving": "Zapisywanie...",
|
||||||
|
"saved": "Zapisano",
|
||||||
|
"saveFailed": "Nie udało się zapisać",
|
||||||
|
"deleted": "Usunięto",
|
||||||
|
"deleteFailed": "Nie udało się usunąć",
|
||||||
|
"cancel": "Anuluj"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Identyfikator klienta",
|
||||||
|
"clientSecret": "Sekret klienta",
|
||||||
|
"issuerUrl": "Adres URL wystawcy",
|
||||||
|
"authUrl": "Adres URL autoryzacji",
|
||||||
|
"tokenUrl": "Adres URL tokena",
|
||||||
|
"userIdentifier": "Ścieżka identyfikatora użytkownika",
|
||||||
|
"displayName": "Ścieżka nazwy wyświetlanej",
|
||||||
|
"scopes": "Zakresy",
|
||||||
|
"userinfoUrl": "Zastąp adres URL informacji o użytkowniku",
|
||||||
|
"groupClaim": "Claim grupy",
|
||||||
|
"groupClaimDesc": "Opcjonalnie. Ścieżka claimu zawierającego grupy użytkownika. Domyślnie: groups, roles, potem group. Użyj tego dla dostawców z niestandardowym claimem (np. Zitadel).",
|
||||||
|
"allowedUsers": "Dozwoleni użytkownicy",
|
||||||
|
"allowedUsersDesc": "Jeden adres e-mail w każdym wierszu. Pozostaw puste, aby zezwolić na wszystkie.",
|
||||||
|
"adminGroup": "Grupa administratorów",
|
||||||
|
"adminGroupDesc": "Użytkownicy w tej grupie otrzymują uprawnienia administratora. Pozostaw puste, aby wyłączyć synchronizację grupy.",
|
||||||
|
"caCert": "Niestandardowy certyfikat CA",
|
||||||
|
"caCertDesc": "Opcjonalnie. Certyfikat CA w formacie PEM dla dostawców OIDC korzystających z prywatnego lub samopodpisanego CA. Pozostaw puste, aby użyć systemowego magazynu zaufanych certyfikatów."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Faça login com {{name}}",
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Ver documentos",
|
||||||
|
"none": "Nenhum provedor de SSO configurado.",
|
||||||
|
"add": "Adicionar provedor",
|
||||||
|
"edit": "Editar provedor",
|
||||||
|
"delete": "Excluir provedor",
|
||||||
|
"deleteConfirm": "Excluir este provedor? Os usuários associados não poderão fazer login.",
|
||||||
|
"name": "Nome de exibição",
|
||||||
|
"type": "Tipo de provedor",
|
||||||
|
"enabled": "Habilitado",
|
||||||
|
"save": "Salvar provedor",
|
||||||
|
"saving": "Salvando...",
|
||||||
|
"saved": "Salvo",
|
||||||
|
"saveFailed": "Falha ao salvar",
|
||||||
|
"deleted": "Excluído",
|
||||||
|
"deleteFailed": "Falha ao excluir",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID do cliente",
|
||||||
|
"clientSecret": "Segredo do cliente",
|
||||||
|
"issuerUrl": "URL do emissor",
|
||||||
|
"authUrl": "URL de autorização",
|
||||||
|
"tokenUrl": "URL do token",
|
||||||
|
"userIdentifier": "Caminho do identificador do usuário",
|
||||||
|
"displayName": "Nome de exibição Caminho",
|
||||||
|
"scopes": "Escopos",
|
||||||
|
"userinfoUrl": "Substituir URL de informações do usuário",
|
||||||
|
"groupClaim": "Reivindicação de grupo",
|
||||||
|
"groupClaimDesc": "Opcional. O caminho da declaração que contém os grupos do usuário. O padrão é grupos, funções e, em seguida, grupo. Use isso para provedores com uma declaração personalizada (por exemplo, Zitadel).",
|
||||||
|
"allowedUsers": "Usuários autorizados",
|
||||||
|
"allowedUsersDesc": "Um e-mail por linha. Deixe em branco para permitir todos.",
|
||||||
|
"adminGroup": "Grupo de Administração",
|
||||||
|
"adminGroupDesc": "Os usuários deste grupo têm privilégios de administrador. Deixe em branco para desativar a sincronização do grupo.",
|
||||||
|
"caCert": "Certificado CA personalizado",
|
||||||
|
"caCertDesc": "Opcional. Certificado CA codificado em PEM para provedores OIDC que utilizam uma CA privada ou autoassinada. Deixe em branco para usar o repositório de certificados confiáveis do sistema."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Iniciar sessão com {{name}}",
|
||||||
|
"loading": "A carregar...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Ver docs",
|
||||||
|
"none": "Nenhum provedor de SSO configurado.",
|
||||||
|
"add": "Adicionar Provedor",
|
||||||
|
"edit": "Editar Provedor",
|
||||||
|
"delete": "Eliminar Provedor",
|
||||||
|
"deleteConfirm": "Eliminar este provedor? Os utilizadores associados não poderão iniciar sessão.",
|
||||||
|
"name": "Nome de apresentação",
|
||||||
|
"type": "Tipo de Provedor",
|
||||||
|
"enabled": "Ativado",
|
||||||
|
"save": "Guardar Provedor",
|
||||||
|
"saving": "A guardar...",
|
||||||
|
"saved": "Guardado",
|
||||||
|
"saveFailed": "Falha ao guardar",
|
||||||
|
"deleted": "Eliminado",
|
||||||
|
"deleteFailed": "Falha ao eliminar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID do cliente",
|
||||||
|
"clientSecret": "Segredo do cliente",
|
||||||
|
"issuerUrl": "URL do emissor",
|
||||||
|
"authUrl": "URL de autorização",
|
||||||
|
"tokenUrl": "URL do token",
|
||||||
|
"userIdentifier": "Caminho do identificador do utilizador",
|
||||||
|
"displayName": "Caminho do nome de exibição",
|
||||||
|
"scopes": "Âmbitos",
|
||||||
|
"userinfoUrl": "Substituir URL do Userinfo",
|
||||||
|
"groupClaim": "Atributo de grupo",
|
||||||
|
"groupClaimDesc": "Opcional. O caminho do atributo que contém os grupos do utilizador. Predefinido para groups, roles e depois group. Utilize para fornecedores com um atributo personalizado (ex.: Zitadel).",
|
||||||
|
"allowedUsers": "Utilizadores permitidos",
|
||||||
|
"allowedUsersDesc": "Um email por linha. Deixe em branco para permitir todos.",
|
||||||
|
"adminGroup": "Grupo de administração",
|
||||||
|
"adminGroupDesc": "Os utilizadores neste grupo recebem privilégios de administrador. Deixe em branco para desativar a sincronização de grupos.",
|
||||||
|
"caCert": "Certificado CA personalizado",
|
||||||
|
"caCertDesc": "Opcional. Certificado CA codificado em PEM para fornecedores OIDC que utilizam uma CA privada ou autoassinada. Deixe em branco para utilizar o repositório de confiança do sistema."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Autentificare cu {{name}}",
|
||||||
|
"loading": "Încărcare...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Vizualizați documentele",
|
||||||
|
"none": "Nu sunt configurați furnizori SSO.",
|
||||||
|
"add": "Adăugați furnizor",
|
||||||
|
"edit": "Editați furnizorul",
|
||||||
|
"delete": "Ștergeți furnizorul",
|
||||||
|
"deleteConfirm": "Ștergeți acest furnizor? Utilizatorii asociați nu se vor putea conecta.",
|
||||||
|
"name": "Numele de afișare",
|
||||||
|
"type": "Tipul furnizorului",
|
||||||
|
"enabled": "Activat",
|
||||||
|
"save": "Salvați furnizorul",
|
||||||
|
"saving": "Economisire...",
|
||||||
|
"saved": "Salvat",
|
||||||
|
"saveFailed": "Salvarea nu a reușit",
|
||||||
|
"deleted": "Șters",
|
||||||
|
"deleteFailed": "Ștergerea nu a reușit",
|
||||||
|
"cancel": "Anula"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ID client",
|
||||||
|
"clientSecret": "Secretul clientului",
|
||||||
|
"issuerUrl": "URL-ul emitentului",
|
||||||
|
"authUrl": "URL de autorizare",
|
||||||
|
"tokenUrl": "Adresa URL a jetonului",
|
||||||
|
"userIdentifier": "Calea de identificare a utilizatorului",
|
||||||
|
"displayName": "Calea numelui afișat",
|
||||||
|
"scopes": "Scopuri",
|
||||||
|
"userinfoUrl": "Suprascrieți adresa URL a informațiilor utilizatorului",
|
||||||
|
"groupClaim": "Revendicare de grup",
|
||||||
|
"groupClaimDesc": "Opțional. Calea de revendicare care conține grupurile utilizatorului. Implicit, se utilizează grupuri, roluri, apoi grup. Folosiți această opțiune pentru furnizorii cu o revendicare personalizată (de exemplu, Zitadel).",
|
||||||
|
"allowedUsers": "Utilizatori permiși",
|
||||||
|
"allowedUsersDesc": "Un e-mail pe rând. Lăsați necompletat pentru a permite accesul tuturor.",
|
||||||
|
"adminGroup": "Grup de administratori",
|
||||||
|
"adminGroupDesc": "Utilizatorii din acest grup au drepturi de administrator. Lăsați câmpul gol pentru a dezactiva sincronizarea grupului.",
|
||||||
|
"caCert": "Certificat CA personalizat",
|
||||||
|
"caCertDesc": "Opțional. Certificat CA codificat PEM pentru furnizorii OIDC care utilizează un CA privat sau autosemnat. Lăsați câmpul gol pentru a utiliza depozitul de încredere al sistemului."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Войти через {{name}}",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Документация",
|
||||||
|
"none": "Нет настроенных провайдеров SSO.",
|
||||||
|
"add": "Добавить провайдера",
|
||||||
|
"edit": "Редактировать провайдера",
|
||||||
|
"delete": "Удалить провайдера",
|
||||||
|
"deleteConfirm": "Удалить провайдера? Связанные пользователи не смогут войти.",
|
||||||
|
"name": "Отображаемое имя",
|
||||||
|
"type": "Тип провайдера",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"save": "Сохранить провайдера",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"saveFailed": "Не удалось сохранить",
|
||||||
|
"deleted": "Удалено",
|
||||||
|
"deleteFailed": "Не удалось удалить",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "Client ID",
|
||||||
|
"clientSecret": "Client Secret",
|
||||||
|
"issuerUrl": "URL издателя",
|
||||||
|
"authUrl": "URL авторизации",
|
||||||
|
"tokenUrl": "URL токена",
|
||||||
|
"userIdentifier": "Путь к идентификатору пользователя",
|
||||||
|
"displayName": "Путь к отображаемому имени",
|
||||||
|
"scopes": "Области",
|
||||||
|
"userinfoUrl": "Переопределить URL Userinfo",
|
||||||
|
"groupClaim": "Утверждение группы",
|
||||||
|
"groupClaimDesc": "Необязательно. Путь к утверждению, содержащему группы пользователя. По умолчанию проверяются groups, roles, затем group. Используйте для провайдеров с настраиваемым утверждением (например, Zitadel).",
|
||||||
|
"allowedUsers": "Разрешенные пользователи",
|
||||||
|
"allowedUsersDesc": "По одному email на строку. Оставьте пустым, чтобы разрешить всем.",
|
||||||
|
"adminGroup": "Группа администраторов",
|
||||||
|
"adminGroupDesc": "Пользователи в этой группе получают права администратора. Оставьте пустым, чтобы отключить синхронизацию групп.",
|
||||||
|
"caCert": "Пользовательский сертификат ЦС",
|
||||||
|
"caCertDesc": "Необязательно. Сертификат ЦС в формате PEM для провайдеров OIDC, использующих частный или самоподписанный ЦС. Оставьте пустым, чтобы использовать системное хранилище доверенных сертификатов."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"loginWithProvider": "Пријавите се са {{name}}",
|
||||||
|
"loading": "Учитавање...",
|
||||||
|
"providers": {
|
||||||
|
"docsLink": "Прикажи документе",
|
||||||
|
"none": "Није конфигурисан ниједан SSO добављач.",
|
||||||
|
"add": "Додај добављача",
|
||||||
|
"edit": "Измени добављача",
|
||||||
|
"delete": "Обриши добављача",
|
||||||
|
"deleteConfirm": "Да ли желите да обришете овог добављача? Повезани корисници неће моћи да се пријаве.",
|
||||||
|
"name": "Приказано име",
|
||||||
|
"type": "Тип добављача",
|
||||||
|
"enabled": "Омогућено",
|
||||||
|
"save": "Сачувај добављача",
|
||||||
|
"saving": "Чување...",
|
||||||
|
"saved": "Сачувано",
|
||||||
|
"saveFailed": "Чување није успело",
|
||||||
|
"deleted": "Избрисано",
|
||||||
|
"deleteFailed": "Брисање није успело",
|
||||||
|
"cancel": "Откажи"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"clientId": "ИД клијента",
|
||||||
|
"clientSecret": "Тајна клијентска порука",
|
||||||
|
"issuerUrl": "URL издаваоца",
|
||||||
|
"authUrl": "URL за ауторизацију",
|
||||||
|
"tokenUrl": "URL токена",
|
||||||
|
"userIdentifier": "Путања идентификатора корисника",
|
||||||
|
"displayName": "Путања приказаног имена",
|
||||||
|
"scopes": "Опсези",
|
||||||
|
"userinfoUrl": "Замени URL са корисничким информацијама",
|
||||||
|
"groupClaim": "Групни захтев",
|
||||||
|
"groupClaimDesc": "Опционо. Путања захтева која садржи групе корисника. Подразумевано је да су групе, улоге, па група. Користите ово за добављаче са прилагођеним захтевом (нпр. Zitadel).",
|
||||||
|
"allowedUsers": "Дозвољени корисници",
|
||||||
|
"allowedUsersDesc": "Једна имејл адреса по реду. Оставите празно да бисте дозволили све.",
|
||||||
|
"adminGroup": "Администраторска група",
|
||||||
|
"adminGroupDesc": "Корисницима у овој групи је додељена администраторска позиција. Оставите празно да бисте онемогућили синхронизацију групе.",
|
||||||
|
"caCert": "Прилагођени CA сертификат",
|
||||||
|
"caCertDesc": "Опционо. PEM-кодирани CA сертификат за OIDC добављаче који користе приватни или самопотписани CA. Оставите празно да бисте користили складиште поверења система."
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user