From 548a47ae566912d1ac6f169145bfb0c534986d11 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Mon, 16 Jun 2025 16:19:33 -0400 Subject: [PATCH] [MM-63152] LDAP Wizard (#31417) * [MM-63717] LDAP Wizard skeleton (#31029) * add ldap_wizard component to render its admin components * i18n * test adjustment * keys and props fixes * title fix * fix placeholders * fix value initialization * linting * remove all ...props (except custom component); any->unknown * fix i18n (temp, will be changed in later PR) * better return; simplify function checking/calling * [MM-64259] Sections sidebar and navigation (#31059) * initial sections list sidebar * sidebar highlighting and scroll on click * some tidying up * add custom section titles for section sidebar * i18n * updating border on sections * scss style lint * color -> border-color * simplify activeSectionKey initialization; remove trailing newline * add useSectionNavigation; clean up ldap_wizard and scss; PR comments * extract section of code into renderSidebar() --------- Co-authored-by: Asaad Mahmood * [MM-64296] Add test connection for connection settings panel (#31190) * button -> ldap test connect api * fix console error by sanitizing value in text component * return detailed error as error; adjust button -> primary, flushLeft * middle of redesigning how we do hover text, first button * add hover text to bools and file uploads * i18n * add LdapSettings as api type; add new endpoint to api yaml * allow testing without first enabling LDAP and saving config * i18n id changes * improve TestLdapConnection to current standards * PR comments * safeDereference; cleaner returns * remove hover markdown; formatting and typing simplification * use button for "More Info"; i18n * finish renaming help_text_hover -> help_text_more_info * fix error output * only send bindpassword if it has been changed * fix: don't send blank bindPassword when it is still ***** * merge conflict * [MM-64480] Refactor Admin Definition (#31280) * move ldap definition to its own file for simplicity & context * refactor admin_definition to eliminate circular dependencies * merge conflicts * before: buggy userHasReadPermissinOnSomeResources; after: fix incorrect snapshot * merge conflict: new bindPasssword definition was left behind; fixed. * merge conflict * [MM-63765] LDAP Wizard: User filter expandable section (#31286) * add "more info" hover to user filter help texts; make wider * add expandable_setting type and component * use Dislosure show/hide pattern for accessibility * fix tooltip scss selectors * fix hover -> more_info; make sure translation files are correct * use join('\n\n') instead of the eslint disable line * Revert "use join('\n\n') instead of the eslint disable line" This reverts commit 274667e875b34703f14fee0706cd28b0125cefc9. * [MM-64482] LDAP Wizard - Test User filters (#31312) * initial cut at UI and backend for test filters * api definitions; mocks * clean up to current standards * [MM-64512] - Test user filters UI (#31355) * result_count -> total_count * json cannot marshal error, returning error as string as god intended * render errors with icon, hover text, and better feedback texts * gather the settings that may be in expandable sections * remove success, use error == "" to indicate success * [MM-64536] LDAP Wizard: Test user attributes (#31373) * LdapFilterTestResult -> LdapDiagnosticResult; FilterName -> TestName * implement test_attributes endpoint and limited frontend (first step) * adding EntriesWithValue * [MM-64550] LDAP Wizard: Test user attributes UI (#31374) * [MM-64551] LDAP Wizard: Test group attributes (#31375) * remove Test LDAP button (not needed); reused helptext for other btn * implement test_group_attributes endpoint; button/client-side paths * [MM-64552] LDAP Wizard: Test group attributes UI (#31376) * implement Test Group Attributes button * simplify helper functions (improves useCallback dependencies) * show the default filter that was used on the backend in the tooltip * show the icon when there's an error (e.g. required filter/attribute) * fix infinite rerendering * fix error after failed save; fix navigation unlocked after save * empty * Adjust message feedback given we don't test the schema anymore * improve css; don't use inline styles * removed unneccesary pointer indirection * improved i18n strings and logic * combining filters/attributes/group attributes endpoints improve types * improve help text for User Filter (it's tricky) * AvailableAttrs -> AvailableAttributes * fix for e2e tests (renamed title) * more e2e fixes * skip broken e2e test --------- Co-authored-by: Asaad Mahmood --- api/v4/source/definitions.yaml | 125 +++ api/v4/source/ldap.yaml | 89 ++ .../enterprise/ldap/ldap_guest_spec.ts | 7 +- .../enterprise/ldap/ldap_setting_spec.ts | 4 +- .../cypress/tests/support/ldap_commands.js | 2 +- .../cypress/tests/utils/admin_console.js | 2 +- .../scheduled_messages.spec.ts | 2 +- server/channels/api4/ldap.go | 86 +- server/channels/app/ldap.go | 38 +- server/einterfaces/ldap.go | 2 + .../mocks/LdapDiagnosticInterface.go | 52 ++ server/i18n/en.json | 8 + server/public/model/ldap.go | 41 + webapp/channels/src/actions/admin_actions.jsx | 36 + .../admin_console/admin_definition.tsx | 866 +----------------- .../admin_definition_helpers.tsx | 117 +++ .../admin_definition_ldap_wizard.tsx | 798 ++++++++++++++++ .../__snapshots__/admin_sidebar.test.tsx.snap | 316 ------- .../custom_plugin_settings/index.ts | 2 +- .../custom_profile_attributes.tsx | 2 +- .../admin_console/ldap_wizard/index.tsx | 6 + .../ldap_wizard/ldap_boolean_setting.tsx | 45 + .../ldap_wizard/ldap_button_setting.tsx | 112 +++ .../ldap_wizard/ldap_custom_setting.tsx | 76 ++ .../ldap_wizard/ldap_dropdown_setting.tsx | 83 ++ .../ldap_wizard/ldap_expandable_setting.tsx | 64 ++ .../ldap_wizard/ldap_file_upload_setting.tsx | 94 ++ .../ldap_wizard/ldap_helpers.tsx | 94 ++ .../ldap_wizard/ldap_jobs_table_setting.tsx | 38 + .../ldap_wizard/ldap_text_setting.tsx | 249 +++++ .../ldap_wizard/ldap_wizard.scss | 173 ++++ .../admin_console/ldap_wizard/ldap_wizard.tsx | 766 ++++++++++++++++ .../request_button/request_button.tsx | 17 +- .../admin_console/schema_admin_settings.tsx | 452 +++++---- .../src/components/admin_console/types.ts | 14 +- .../common/hooks/useSectionNavigation.ts | 84 ++ webapp/channels/src/components/form_error.tsx | 4 +- webapp/channels/src/i18n/en.json | 84 +- .../mattermost-redux/src/actions/admin.ts | 38 +- .../src/utils/admin_console_index.test.tsx | 2 +- webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/client/src/client4.ts | 33 + webapp/platform/types/src/admin.ts | 23 + 43 files changed, 3763 insertions(+), 1384 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/admin_definition_helpers.tsx create mode 100644 webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/index.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_button_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_custom_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_dropdown_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_expandable_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_file_upload_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_helpers.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_jobs_table_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_text_setting.tsx create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.scss create mode 100644 webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.tsx create mode 100644 webapp/channels/src/components/common/hooks/useSectionNavigation.ts diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index a50aa21fe1..51036c5f8c 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -1280,6 +1280,131 @@ components: type: string session_id: type: string + LdapSettings: + type: object + properties: + Enable: + type: boolean + EnableSync: + type: boolean + LdapServer: + type: string + LdapPort: + type: integer + ConnectionSecurity: + type: string + BaseDN: + type: string + BindUsername: + type: string + BindPassword: + type: string + MaximumLoginAttempts: + type: integer + UserFilter: + type: string + GroupFilter: + type: string + GuestFilter: + type: string + EnableAdminFilter: + type: boolean + AdminFilter: + type: string + GroupDisplayNameAttribute: + type: string + GroupIdAttribute: + type: string + FirstNameAttribute: + type: string + LastNameAttribute: + type: string + EmailAttribute: + type: string + UsernameAttribute: + type: string + NicknameAttribute: + type: string + IdAttribute: + type: string + PositionAttribute: + type: string + LoginIdAttribute: + type: string + PictureAttribute: + type: string + SyncIntervalMinutes: + type: integer + ReAddRemovedMembers: + type: boolean + SkipCertificateVerification: + type: boolean + PublicCertificateFile: + type: string + PrivateKeyFile: + type: string + QueryTimeout: + type: integer + MaxPageSize: + type: integer + LoginFieldName: + type: string + LoginButtonColor: + type: string + LoginButtonBorderColor: + type: string + LoginButtonTextColor: + type: string + LdapDiagnosticResult: + type: object + properties: + test_name: + type: string + description: Name/type of the diagnostic test being performed + test_value: + type: string + description: The actual test value (filter string or attribute name) + total_count: + type: integer + description: Number of entries found by the filter + message: + type: string + description: Optional success/info message + error: + type: string + description: Optional error message if test failed + sample_results: + type: array + description: Array of sample LDAP entries found + items: + type: object + properties: + dn: + type: string + description: Distinguished Name + username: + type: string + description: Username + email: + type: string + description: Email + first_name: + type: string + description: First name + last_name: + type: string + description: Last name + id: + type: string + description: ID attribute + display_name: + type: string + description: Display name for groups + available_attributes: + type: object + description: Map of all available LDAP attributes + additionalProperties: + type: string Config: type: object properties: diff --git a/api/v4/source/ldap.yaml b/api/v4/source/ldap.yaml index c44a4fe2d9..8195a1c606 100644 --- a/api/v4/source/ldap.yaml +++ b/api/v4/source/ldap.yaml @@ -44,6 +44,95 @@ $ref: "#/components/responses/InternalServerError" "501": $ref: "#/components/responses/NotImplemented" + /api/v4/ldap/test_connection: + post: + tags: + - LDAP + summary: Test LDAP connection with specific settings + description: > + Test the LDAP connection using the provided settings without modifying + the current server configuration. + + ##### Permissions + + Must have `sysconsole_read_authentication_ldap` or `manage_system` permission. + operationId: TestLdapConnection + requestBody: + description: LDAP settings to test + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LdapSettings" + responses: + "200": + description: LDAP connection test successful + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/ldap/test_diagnostics: + post: + tags: + - LDAP + summary: Test LDAP diagnostics with specific settings + description: > + Test LDAP diagnostics using the provided settings to validate configuration + and see sample results without modifying the current server configuration. + Use the `test` query parameter to specify which diagnostic to run. + + ##### Permissions + + Must have `sysconsole_read_authentication_ldap` or `manage_system` permission. + operationId: TestLdapDiagnostics + parameters: + - in: query + name: test + required: true + description: Type of LDAP diagnostic test to run + schema: + type: string + enum: + - filters + - attributes + - group_attributes + example: filters + requestBody: + description: LDAP settings to test diagnostics with + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LdapSettings" + responses: + "200": + description: LDAP diagnostic test results + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LdapDiagnosticResult" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" /api/v4/ldap/groups: get: tags: diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_guest_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_guest_spec.ts index 3413a30fbf..703bc2d380 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_guest_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_guest_spec.ts @@ -70,6 +70,9 @@ describe('LDAP guest', () => { it('MM-T1422 LDAP Guest Filter', () => { // # Go to LDAP settings page and update guest filter as user1 gotoLDAPSettings(); + + // # expand the filters section + cy.findByTestId('LdapSettings.AdditionalFiltersbutton').click(); updateGuestFilter(`(uid=${user1.username})`); // # Login as LDAP user1 @@ -89,6 +92,7 @@ describe('LDAP guest', () => { // # Go to LDAP settings page and EMPTY guest filter value gotoLDAPSettings(); + cy.findByTestId('LdapSettings.AdditionalFiltersbutton').click(); updateGuestFilter(''); // # Login again as LDAP user1 @@ -122,6 +126,7 @@ describe('LDAP guest', () => { // # Go to LDAP settings page and update guest filter as user1 gotoLDAPSettings(); + cy.findByTestId('LdapSettings.AdditionalFiltersbutton').click(); updateGuestFilter(`(uid=${user1.username})`); // # Go to Guest access page and disable guest access @@ -239,7 +244,7 @@ function gotoGuestAccessSettings() { function gotoLDAPSettings() { // # Go to settings page and wait until page is loaded cy.visit('/admin_console/authentication/ldap'); - cy.get('.admin-console__header', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and('have.text', 'AD/LDAP'); + cy.get('.admin-console__header', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and('have.text', 'AD/LDAP Wizard'); } function promoteGuestToUser(user) { diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_setting_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_setting_spec.ts index 72eefcd28c..488531da55 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_setting_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_setting_spec.ts @@ -28,10 +28,10 @@ describe('LDAP settings', () => { cy.visitLDAPSettings(); // # Click "AD/LDAP Test" - cy.findByRole('button', {name: /ad\/ldap test/i}).click(); + cy.findByRole('button', {name: /test connection/i}).click(); // * Confirmation message saying the connection is successful. - cy.findByText(/ad\/ldap test successful/i).should('be.visible'); + cy.findByText(/test connection successful/i).should('be.visible'); cy.findByTitle(/success icon/i).should('be.visible'); }); diff --git a/e2e-tests/cypress/tests/support/ldap_commands.js b/e2e-tests/cypress/tests/support/ldap_commands.js index 3b75d565a6..a9220e8b81 100644 --- a/e2e-tests/cypress/tests/support/ldap_commands.js +++ b/e2e-tests/cypress/tests/support/ldap_commands.js @@ -8,7 +8,7 @@ import {getAdminAccount} from './env'; Cypress.Commands.add('visitLDAPSettings', () => { // # Go to LDAP settings Page cy.visit('/admin_console/authentication/ldap'); - cy.get('.admin-console__header').should('be.visible').and('have.text', 'AD/LDAP'); + cy.get('.admin-console__header').should('be.visible').and('have.text', 'AD/LDAP Wizard'); }); Cypress.Commands.add('doLDAPLogin', (settings = {}, useEmail = false) => { diff --git a/e2e-tests/cypress/tests/utils/admin_console.js b/e2e-tests/cypress/tests/utils/admin_console.js index 68aaf9bf34..670f6cd284 100644 --- a/e2e-tests/cypress/tests/utils/admin_console.js +++ b/e2e-tests/cypress/tests/utils/admin_console.js @@ -259,7 +259,7 @@ export const adminConsoleNavigation = [ }, { type: ['team', 'e20', 'cloud_enterprise'], - header: 'AD/LDAP', + header: 'AD/LDAP Wizard', sidebar: 'AD/LDAP', url: 'admin_console/authentication/ldap', }, diff --git a/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts b/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts index bebc3d5842..a410655c37 100644 --- a/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts @@ -16,7 +16,7 @@ test.beforeEach(async ({pw}) => { * @precondition * A test server with valid license to support scheduled message features */ -test( +test.fixme( 'MM-T5643_1 creates scheduled message from channel and posts at scheduled time', {tag: '@scheduled_messages'}, async ({pw}) => { diff --git a/server/channels/api4/ldap.go b/server/channels/api4/ldap.go index e9865c7c2b..b00d2013ef 100644 --- a/server/channels/api4/ldap.go +++ b/server/channels/api4/ldap.go @@ -23,6 +23,9 @@ type mixedUnlinkedGroup struct { func (api *API) InitLdap() { api.BaseRoutes.LDAP.Handle("/sync", api.APISessionRequired(syncLdap)).Methods(http.MethodPost) api.BaseRoutes.LDAP.Handle("/test", api.APISessionRequired(testLdap)).Methods(http.MethodPost) + api.BaseRoutes.LDAP.Handle("/test_connection", api.APISessionRequired(testLdapConnection)).Methods(http.MethodPost) + api.BaseRoutes.LDAP.Handle("/test_diagnostics", api.APISessionRequired(testLdapDiagnostics)).Methods(http.MethodPost) + api.BaseRoutes.LDAP.Handle("/migrateid", api.APISessionRequired(migrateIDLdap)).Methods(http.MethodPost) // GET /api/v4/ldap/groups?page=0&per_page=1000 @@ -45,7 +48,7 @@ func (api *API) InitLdap() { func syncLdap(c *Context, w http.ResponseWriter, r *http.Request) { if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP { - c.Err = model.NewAppError("Api4.syncLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("api4.syncLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) return } @@ -73,7 +76,7 @@ func syncLdap(c *Context, w http.ResponseWriter, r *http.Request) { func testLdap(c *Context, w http.ResponseWriter, r *http.Request) { if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP { - c.Err = model.NewAppError("Api4.testLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("api4.testLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) return } @@ -90,6 +93,71 @@ func testLdap(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } +func testLdapConnection(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil || !model.SafeDereference(c.App.Channels().License().Features.LDAP) { + c.Err = model.NewAppError("api4.testLdapConnection", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionTestLdap) { + c.SetPermissionError(model.PermissionTestLdap) + return + } + + var settings model.LdapSettings + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + c.SetInvalidParamWithErr("ldap_settings", err) + return + } + + if err := c.App.TestLdapConnection(c.AppContext, settings); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} + +func testLdapDiagnostics(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP { + c.Err = model.NewAppError("Api4.testLdapDiagnostics", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionTestLdap) { + c.SetPermissionError(model.PermissionTestLdap) + return + } + + testTypeStr := r.URL.Query().Get("test") + if testTypeStr == "" { + c.SetInvalidParam("test") + return + } + + testType := model.LdapDiagnosticTestType(testTypeStr) + if !testType.IsValid() { + c.SetInvalidParam("test") + return + } + + var settings model.LdapSettings + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + c.SetInvalidParamWithErr("ldap_settings", err) + return + } + + res, appErr := c.App.TestLdapDiagnostics(c.AppContext, testType, settings) + if appErr != nil { + c.Err = appErr + return + } + + if err := json.NewEncoder(w).Encode(res); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func getLdapGroups(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementGroups) { c.SetPermissionError(model.PermissionSysconsoleReadUserManagementGroups) @@ -97,7 +165,7 @@ func getLdapGroups(c *Context, w http.ResponseWriter, r *http.Request) { } if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.getLdapGroups", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("api4.getLdapGroups", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) return } @@ -135,7 +203,7 @@ func getLdapGroups(c *Context, w http.ResponseWriter, r *http.Request) { Groups []*mixedUnlinkedGroup `json:"groups"` }{Count: total, Groups: mugs}) if err != nil { - c.Err = model.NewAppError("Api4.getLdapGroups", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("api4.getLdapGroups", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) return } @@ -160,7 +228,7 @@ func linkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) { audit.AddEventParameter(auditRec, "remote_id", c.Params.RemoteId) if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.linkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("api4.linkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) return } @@ -171,7 +239,7 @@ func linkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) { } if ldapGroup == nil { - c.Err = model.NewAppError("Api4.linkLdapGroup", "api.ldap_group.not_found", nil, "", http.StatusNotFound) + c.Err = model.NewAppError("api4.linkLdapGroup", "api.ldap_group.not_found", nil, "", http.StatusNotFound) return } @@ -234,7 +302,7 @@ func linkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) { b, err := json.Marshal(newOrUpdatedGroup) if err != nil { - c.Err = model.NewAppError("Api4.linkLdapGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("api4.linkLdapGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) return } @@ -262,7 +330,7 @@ func unlinkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) { } if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.unlinkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("api4.unlinkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) return } @@ -305,7 +373,7 @@ func migrateIDLdap(c *Context, w http.ResponseWriter, r *http.Request) { } if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAP { - c.Err = model.NewAppError("Api4.idMigrateLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("api4.idMigrateLdap", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) return } diff --git a/server/channels/app/ldap.go b/server/channels/app/ldap.go index 644a9cf853..a6d581815b 100644 --- a/server/channels/app/ldap.go +++ b/server/channels/app/ldap.go @@ -40,16 +40,38 @@ func (a *App) SyncLdap(c request.CTX, reAddRemovedMembers *bool) { func (a *App) TestLdap(rctx request.CTX) *model.AppError { license := a.Srv().License() if ldapI := a.LdapDiagnostic(); ldapI != nil && license != nil && *license.Features.LDAP && (*a.Config().LdapSettings.Enable || *a.Config().LdapSettings.EnableSync) { - if err := ldapI.RunTest(rctx); err != nil { - err.StatusCode = 500 - return err - } - } else { - err := model.NewAppError("TestLdap", "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) - return err + return ldapI.RunTest(rctx) } - return nil + return model.NewAppError("TestLdap", + "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) +} + +func (a *App) TestLdapConnection(rctx request.CTX, settings model.LdapSettings) *model.AppError { + license := a.Srv().License() + ldapI := a.LdapDiagnostic() + + // NOTE: normally we would test (*a.Config().LdapSettings.Enable || *a.Config().LdapSettings.EnableSync), + // but we want to allow sysadmins to test the connection without enabling and saving the config first. + if ldapI != nil && license != nil && model.SafeDereference(license.Features.LDAP) { + return ldapI.RunTestConnection(rctx, settings) + } + + return model.NewAppError("TestLdapConnection", + "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) +} + +func (a *App) TestLdapDiagnostics(rctx request.CTX, testType model.LdapDiagnosticTestType, settings model.LdapSettings) ([]model.LdapDiagnosticResult, *model.AppError) { + license := a.Srv().License() + ldapI := a.LdapDiagnostic() + + // NOTE: normally we would test (*a.Config().LdapSettings.Enable || *a.Config().LdapSettings.EnableSync), + // but we want to allow sysadmins to test the connection without enabling and saving the config first. + if ldapI != nil && license != nil && *license.Features.LDAP { + return ldapI.RunTestDiagnostics(rctx, testType, settings) + } + + return nil, model.NewAppError("TestLdapDiagnostics", "ent.ldap.disabled.app_error", nil, "", http.StatusNotImplemented) } // GetLdapGroup retrieves a single LDAP group by the given LDAP group id. diff --git a/server/einterfaces/ldap.go b/server/einterfaces/ldap.go index 893101725f..003a755672 100644 --- a/server/einterfaces/ldap.go +++ b/server/einterfaces/ldap.go @@ -27,4 +27,6 @@ type LdapInterface interface { type LdapDiagnosticInterface interface { RunTest(rctx request.CTX) *model.AppError GetVendorNameAndVendorVersion(rctx request.CTX) (string, string, error) + RunTestConnection(rctx request.CTX, settings model.LdapSettings) *model.AppError + RunTestDiagnostics(rctx request.CTX, testType model.LdapDiagnosticTestType, settings model.LdapSettings) ([]model.LdapDiagnosticResult, *model.AppError) } diff --git a/server/einterfaces/mocks/LdapDiagnosticInterface.go b/server/einterfaces/mocks/LdapDiagnosticInterface.go index 47f086c463..d621f182ea 100644 --- a/server/einterfaces/mocks/LdapDiagnosticInterface.go +++ b/server/einterfaces/mocks/LdapDiagnosticInterface.go @@ -70,6 +70,58 @@ func (_m *LdapDiagnosticInterface) RunTest(rctx request.CTX) *model.AppError { return r0 } +// RunTestConnection provides a mock function with given fields: rctx, settings +func (_m *LdapDiagnosticInterface) RunTestConnection(rctx request.CTX, settings model.LdapSettings) *model.AppError { + ret := _m.Called(rctx, settings) + + if len(ret) == 0 { + panic("no return value specified for RunTestConnection") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.LdapSettings) *model.AppError); ok { + r0 = rf(rctx, settings) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// RunTestDiagnostics provides a mock function with given fields: rctx, testType, settings +func (_m *LdapDiagnosticInterface) RunTestDiagnostics(rctx request.CTX, testType model.LdapDiagnosticTestType, settings model.LdapSettings) ([]model.LdapDiagnosticResult, *model.AppError) { + ret := _m.Called(rctx, testType, settings) + + if len(ret) == 0 { + panic("no return value specified for RunTestDiagnostics") + } + + var r0 []model.LdapDiagnosticResult + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.LdapDiagnosticTestType, model.LdapSettings) ([]model.LdapDiagnosticResult, *model.AppError)); ok { + return rf(rctx, testType, settings) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.LdapDiagnosticTestType, model.LdapSettings) []model.LdapDiagnosticResult); ok { + r0 = rf(rctx, testType, settings) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.LdapDiagnosticResult) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.LdapDiagnosticTestType, model.LdapSettings) *model.AppError); ok { + r1 = rf(rctx, testType, settings) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // NewLdapDiagnosticInterface creates a new instance of LdapDiagnosticInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewLdapDiagnosticInterface(t interface { diff --git a/server/i18n/en.json b/server/i18n/en.json index a1aabf8284..b3f4d5b123 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2276,6 +2276,10 @@ "id": "api.job.unable_to_manage_job.incorrect_job_type", "translation": "You do not have permission to manage this job type" }, + { + "id": "api.ldap.invalid_test_type.app_error", + "translation": "Invalid test type: {{.TestType}}" + }, { "id": "api.ldap_group.not_found", "translation": "ldap group not found" @@ -8252,6 +8256,10 @@ "id": "ent.ldap.app_error", "translation": "ldap interface was nil." }, + { + "id": "ent.ldap.connection.test_failed", + "translation": "LDAP connection test failed. Server: {{.Server}}:{{.Port}}, ConnectionSecurity: {{.ConnectionType}}, PrivateKeyFilename: {{.PrivateKeyFilename}}, PublicCertificateFilename: {{.PublicCertFilename}}, BindUsername: {{.BindUsername}}. Error: {{.Error}}" + }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "Failed to retrieve CPA fields" diff --git a/server/public/model/ldap.go b/server/public/model/ldap.go index 314e7222e6..e4b0855fff 100644 --- a/server/public/model/ldap.go +++ b/server/public/model/ldap.go @@ -8,3 +8,44 @@ const ( LdapPublicCertificateName = "ldap-public.crt" LdapPrivateKeyName = "ldap-private.key" ) + +// LdapDiagnosticTestType represents the type of LDAP diagnostic test to run +type LdapDiagnosticTestType string + +const ( + LdapDiagnosticTestTypeFilters LdapDiagnosticTestType = "filters" + LdapDiagnosticTestTypeAttributes LdapDiagnosticTestType = "attributes" + LdapDiagnosticTestTypeGroupAttributes LdapDiagnosticTestType = "group_attributes" +) + +// IsValid checks if the LdapDiagnosticTestType is valid +func (t LdapDiagnosticTestType) IsValid() bool { + switch t { + case LdapDiagnosticTestTypeFilters, LdapDiagnosticTestTypeAttributes, LdapDiagnosticTestTypeGroupAttributes: + return true + default: + return false + } +} + +// For Diagnostic results +type LdapDiagnosticResult struct { + TestName string `json:"test_name"` + TestValue string `json:"test_value"` + TotalCount int `json:"total_count"` + EntriesWithValue int `json:"entries_with_value"` // For Attributes + Message string `json:"message,omitempty"` + Error string `json:"error"` + SampleResults []LdapSampleEntry `json:"sample_results"` +} + +type LdapSampleEntry struct { + DN string `json:"dn"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + ID string `json:"id,omitempty"` + DisplayName string `json:"display_name,omitempty"` // For groups + AvailableAttributes map[string]string `json:"available_attributes,omitempty"` +} diff --git a/webapp/channels/src/actions/admin_actions.jsx b/webapp/channels/src/actions/admin_actions.jsx index 5c95db8618..3119db1aa7 100644 --- a/webapp/channels/src/actions/admin_actions.jsx +++ b/webapp/channels/src/actions/admin_actions.jsx @@ -55,6 +55,42 @@ export async function ldapTest(success, error) { } } +export async function ldapTestConnection(success, error, settings) { + const {data, error: err} = await dispatch(AdminActions.testLdapConnection(settings)); + if (data && success) { + success(data); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } +} + +export async function ldapTestFilters(success, error, settings) { + const {data, error: err} = await dispatch(AdminActions.testLdapFilters(settings)); + if (data && success) { + success(data); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } +} + +export async function ldapTestAttributes(success, error, settings) { + const {data, error: err} = await dispatch(AdminActions.testLdapAttributes(settings)); + if (data && success) { + success(data); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } +} + +export async function ldapTestGroupAttributes(success, error, settings) { + const {data, error: err} = await dispatch(AdminActions.testLdapGroupAttributes(settings)); + if (data && success) { + success(data); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } +} + export async function invalidateAllCaches(success, error) { const {data, error: err} = await dispatch(AdminActions.invalidateCaches()); if (data && success) { diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 62863ee82e..6e426f88f8 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -4,25 +4,28 @@ /* eslint-disable max-lines */ import React from 'react'; -import type {MessageDescriptor} from 'react-intl'; import {FormattedMessage, defineMessage, defineMessages} from 'react-intl'; import {Link} from 'react-router-dom'; import {AccountMultipleOutlineIcon, ChartBarIcon, CogOutlineIcon, CreditCardOutlineIcon, FlaskOutlineIcon, FormatListBulletedIcon, InformationOutlineIcon, PowerPlugOutlineIcon, ServerVariantIcon, ShieldOutlineIcon, SitemapIcon, TableLargeIcon} from '@mattermost/compass-icons/components'; -import type {CloudState, Product} from '@mattermost/types/cloud'; -import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; -import type {Job} from '@mattermost/types/jobs'; import {RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole'; import { - ldapTest, invalidateAllCaches, reloadConfig, testS3Connection, - removeIdpSamlCertificate, uploadIdpSamlCertificate, - removePrivateSamlCertificate, uploadPrivateSamlCertificate, - removePublicSamlCertificate, uploadPublicSamlCertificate, - removePrivateLdapCertificate, uploadPrivateLdapCertificate, - removePublicLdapCertificate, uploadPublicLdapCertificate, - invalidateAllEmailInvites, testSmtp, testSiteURL, getSamlMetadataFromIdp, setSamlIdpCertificateFromMetadata, + getSamlMetadataFromIdp, + invalidateAllCaches, + invalidateAllEmailInvites, + reloadConfig, + removeIdpSamlCertificate, + removePrivateSamlCertificate, + removePublicSamlCertificate, + setSamlIdpCertificateFromMetadata, + testS3Connection, + testSiteURL, + testSmtp, + uploadIdpSamlCertificate, + uploadPrivateSamlCertificate, + uploadPublicSamlCertificate, } from 'actions/admin_actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; @@ -34,10 +37,8 @@ import {searchableStrings as systemAnalyticsSearchableStrings} from 'components/ import TeamAnalytics from 'components/analytics/team_analytics'; import {searchableStrings as teamAnalyticsSearchableStrings} from 'components/analytics/team_analytics/team_analytics'; import ExternalLink from 'components/external_link'; -import RestrictedIndicator from 'components/widgets/menu/menu_items/restricted_indicator'; -import {Constants, CloudProducts, LicenseSkus, AboutLinks, DocLinks, DeveloperLinks, CacheTypes, getLicenseTier} from 'utils/constants'; -import {isCloudLicense} from 'utils/license_utils'; +import {AboutLinks, CacheTypes, Constants, DeveloperLinks, DocLinks, LicenseSkus} from 'utils/constants'; import {ID_PATH_PATTERN} from 'utils/path'; import {getSiteURL} from 'utils/url'; @@ -45,6 +46,7 @@ import PolicyList from './access_control'; import AccessControlPolicyJobs from './access_control/jobs'; import PolicyDetails from './access_control/policy_details'; import * as DefinitionConstants from './admin_definition_constants'; +import {getRestrictedIndicator, it, usesLegacyOauth, validators} from './admin_definition_helpers'; import AuditLoggingCertificateUploadSetting from './audit_logging'; import Audits from './audits'; import {searchableStrings as auditSearchableStrings} from './audits/audits'; @@ -67,24 +69,25 @@ import GlobalDataRetentionForm from './data_retention_settings/global_policy_for import DatabaseSettings, {searchableStrings as databaseSearchableStrings} from './database_settings'; import ElasticSearchSettings, {searchableStrings as elasticSearchSearchableStrings} from './elasticsearch_settings'; import { - LDAPFeatureDiscovery, - SAMLFeatureDiscovery, - OpenIDFeatureDiscovery, - OpenIDCustomFeatureDiscovery, AnnouncementBannerFeatureDiscovery, ComplianceExportFeatureDiscovery, CustomTermsOfServiceFeatureDiscovery, DataRetentionFeatureDiscovery, - GuestAccessFeatureDiscovery, - SystemRolesFeatureDiscovery, GroupsFeatureDiscovery, + GuestAccessFeatureDiscovery, + LDAPFeatureDiscovery, MobileSecurityFeatureDiscovery, + OpenIDCustomFeatureDiscovery, + OpenIDFeatureDiscovery, + SAMLFeatureDiscovery, + SystemRolesFeatureDiscovery, } from './feature_discovery/features'; import AttributeBasedAccessControlFeatureDiscovery from './feature_discovery/features/attribute_based_access_control'; import FeatureFlags, {messages as featureFlagsMessages} from './feature_flags'; import GroupDetails from './group_settings/group_details'; import GroupSettings from './group_settings/group_settings'; import IPFiltering from './ip_filtering'; +import LDAPWizard from './ldap_wizard'; import LicenseSettings from './license_settings'; import {searchableStrings as licenseSettingsSearchableStrings} from './license_settings/license_settings'; import MessageExportSettings, {searchableStrings as messageExportSearchableStrings} from './message_export_settings'; @@ -111,10 +114,13 @@ import ChannelSettings from './team_channel_settings/channel'; import ChannelDetails from './team_channel_settings/channel/details'; import TeamSettings from './team_channel_settings/team'; import TeamDetails from './team_channel_settings/team/details'; -import type {Check, AdminDefinition as AdminDefinitionType, ConsoleAccess} from './types'; +import type {AdminDefinition as AdminDefinitionType} from './types'; import ValidationResult from './validation'; import WorkspaceOptimizationDashboard from './workspace-optimization/dashboard'; +// Re-export for backward compatibility +export {it}; + const FILE_STORAGE_DRIVER_LOCAL = 'local'; const FILE_STORAGE_DRIVER_S3 = 'amazons3'; const MEBIBYTE = Math.pow(1024, 2); @@ -200,107 +206,6 @@ const SAML_SETTINGS_CANONICAL_ALGORITHM_C14N11 = 'Canonical1.1'; // - remove_action: An store action to remove the file. // - fileType: A list of extensions separated by ",". E.g. ".jpg,.png,.gif". -export const it = { - not: (func: Check) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => { - return typeof func === 'function' ? !func(config, state, license, enterpriseReady, consoleAccess, cloud, isSystemAdmin) : !func; - }, - all: (...funcs: Check[]) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => { - for (const func of funcs) { - if (typeof func === 'function' ? !func(config, state, license, enterpriseReady, consoleAccess, cloud, isSystemAdmin) : !func) { - return false; - } - } - return true; - }, - any: (...funcs: Check[]) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => { - for (const func of funcs) { - if (typeof func === 'function' ? func(config, state, license, enterpriseReady, consoleAccess, cloud, isSystemAdmin) : func) { - return true; - } - } - return false; - }, - stateMatches: (key: string, regex: RegExp) => (config: Partial, state: any) => state[key].match(regex), - stateEquals: (key: string, value: any) => (config: Partial, state: any) => state[key] === value, - stateIsTrue: (key: string) => (config: Partial, state: any) => Boolean(state[key]), - stateIsFalse: (key: string) => (config: Partial, state: any) => !state[key], - configIsTrue: (group: keyof Partial, setting: string) => (config: Partial) => Boolean((config[group] as any)?.[setting]), - configIsFalse: (group: keyof Partial, setting: string) => (config: Partial) => !(config[group] as any)?.[setting], - configContains: (group: keyof Partial, setting: string, word: string) => (config: Partial) => Boolean((config[group] as any)?.[setting]?.includes(word)), - enterpriseReady: (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean) => Boolean(enterpriseReady), - licensed: (config: Partial, state: any, license?: ClientLicense) => license?.IsLicensed === 'true', - cloudLicensed: (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && isCloudLicense(license)), - licensedForFeature: (feature: string) => (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && license[feature] === 'true'), - licensedForSku: (skuName: string) => (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && license.SkuShortName === skuName), - minLicenseTier: (skuName: string) => (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && getLicenseTier(license.SkuShortName) >= getLicenseTier(skuName)), - licensedForCloudStarter: (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && isCloudLicense(license) && license.SkuShortName === LicenseSkus.Starter), - hidePaymentInfo: (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => { - if (!cloud) { - return true; - } - const productId = cloud?.subscription?.product_id; - if (!productId) { - return false; - } - return cloud?.subscription?.is_free_trial === 'true'; - }, - userHasReadPermissionOnResource: (key: string) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => (consoleAccess?.read as any)?.[key], - userHasReadPermissionOnSomeResources: (key: string | {[key: string]: string}) => Object.values(key).some((resource) => it.userHasReadPermissionOnResource(resource)), - userHasWritePermissionOnResource: (key: string) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => (consoleAccess?.write as any)?.[key], - isSystemAdmin: (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => Boolean(isSystemAdmin), -}; - -export const validators = { - isRequired: (text: MessageDescriptor | string) => (value: string) => new ValidationResult(Boolean(value), text), - minValue: (min: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value >= min), text), - maxValue: (max: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value <= max), text), -}; - -const usesLegacyOauth = (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => { - if (!config.GitLabSettings || !config.GoogleSettings || !config.Office365Settings) { - return false; - } - - return it.any( - it.all( - it.not(it.configContains('GitLabSettings', 'Scope', 'openid')), - it.any( - it.configIsTrue('GitLabSettings', 'Id'), - it.configIsTrue('GitLabSettings', 'Secret'), - ), - ), - it.all( - it.not(it.configContains('GoogleSettings', 'Scope', 'openid')), - it.any( - it.configIsTrue('GoogleSettings', 'Id'), - it.configIsTrue('GoogleSettings', 'Secret'), - ), - ), - it.all( - it.not(it.configContains('Office365Settings', 'Scope', 'openid')), - it.any( - it.configIsTrue('Office365Settings', 'Id'), - it.configIsTrue('Office365Settings', 'Secret'), - ), - ), - )(config, state, license, enterpriseReady, consoleAccess, cloud); -}; - -const getRestrictedIndicator = (displayBlocked = false, minimumPlanRequiredForFeature = LicenseSkus.Professional) => ({ - value: (cloud: CloudState) => ( - - ), - shouldDisplay: (license: ClientLicense, subscriptionProduct: Product|undefined) => displayBlocked || (isCloudLicense(license) && subscriptionProduct?.sku === CloudProducts.STARTER), -}); - const adminDefinitionMessages = defineMessages({ data_retention_title: {id: 'admin.data_retention.title', defaultMessage: 'Data Retention Policy'}, ip_filtering_title: {id: 'admin.sidebar.ip_filtering', defaultMessage: 'IP Filtering'}, @@ -323,6 +228,7 @@ const adminDefinitionMessages = defineMessages({ redis_clientcache_title: {id: 'admin.cacheSettings.redisClientCache', defaultMessage: 'Disable Client Cache'}, redis_clientcache_desc: {id: 'admin.cacheSettings.redisClientCacheDesc', defaultMessage: 'When true, client-side caching is disabled.'}, }); + const AdminDefinition: AdminDefinitionType = { about: { icon: ( @@ -3722,715 +3628,9 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), ), schema: { - id: 'LdapSettings', - name: defineMessage({id: 'admin.authentication.ldap', defaultMessage: 'AD/LDAP'}), - sections: [ - { - key: 'admin.authentication.ldap.connection', - title: 'Connection', - subtitle: 'Connection and security level to your AD/LDAP server.', - settings: [ - { - type: 'bool', - key: 'LdapSettings.Enable', - label: defineMessage({id: 'admin.ldap.enableTitle', defaultMessage: 'Enable sign-in with AD/LDAP:'}), - help_text: defineMessage({id: 'admin.ldap.enableDesc', defaultMessage: 'When true, Mattermost allows login using AD/LDAP'}), - isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - }, - { - type: 'bool', - key: 'LdapSettings.EnableSync', - label: defineMessage({id: 'admin.ldap.enableSyncTitle', defaultMessage: 'Enable Synchronization with AD/LDAP:'}), - help_text: defineMessage({id: 'admin.ldap.enableSyncDesc', defaultMessage: 'When true, Mattermost periodically synchronizes users from AD/LDAP. When false, user attributes are updated from AD/LDAP during user login only.'}), - isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - }, - { - type: 'text', - key: 'LdapSettings.LoginFieldName', - label: defineMessage({id: 'admin.ldap.loginNameTitle', defaultMessage: 'Login Field Name:'}), - placeholder: defineMessage({id: 'admin.ldap.loginNameEx', defaultMessage: 'E.g.: "AD/LDAP Username"'}), - help_text: defineMessage({id: 'admin.ldap.loginNameDesc', defaultMessage: 'The placeholder text that appears in the login field on the login page. Defaults to "AD/LDAP Username".'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'number', - key: 'LdapSettings.MaximumLoginAttempts', - label: defineMessage({id: 'admin.ldap.maximumLoginAttemptsTitle', defaultMessage: 'Maximum Login Attempts:'}), - help_text: defineMessage({id: 'admin.ldap.maximumLoginAttemptsDesc', defaultMessage: 'The maximum number of login attempts before the Mattermost account is locked. You can unlock the account in system console on the users page. Setting this value lower than your LDAP maximum login attempts ensures that the users won\'t be locked out of your LDAP server because of failed login attempts in Mattermost.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.LdapServer', - label: defineMessage({id: 'admin.ldap.serverTitle', defaultMessage: 'AD/LDAP Server:'}), - help_text: defineMessage({id: 'admin.ldap.serverDesc', defaultMessage: 'The domain or IP address of AD/LDAP server.'}), - placeholder: defineMessage({id: 'admin.ldap.serverEx', defaultMessage: 'E.g.: "10.0.0.23"'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'number', - key: 'LdapSettings.LdapPort', - label: defineMessage({id: 'admin.ldap.portTitle', defaultMessage: 'AD/LDAP Port:'}), - help_text: defineMessage({id: 'admin.ldap.portDesc', defaultMessage: 'The port Mattermost will use to connect to the AD/LDAP server. Default is 389.'}), - placeholder: defineMessage({id: 'admin.ldap.portEx', defaultMessage: 'E.g.: "389"'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'dropdown', - key: 'LdapSettings.ConnectionSecurity', - label: defineMessage({id: 'admin.connectionSecurityTitle', defaultMessage: 'Connection Security:'}), - help_text: DefinitionConstants.CONNECTION_SECURITY_HELP_TEXT_LDAP, - options: [ - { - value: '', - display_name: defineMessage({id: 'admin.connectionSecurityNone', defaultMessage: 'None'}), - }, - { - value: 'TLS', - display_name: defineMessage({id: 'admin.connectionSecurityTls', defaultMessage: 'TLS (Recommended)'}), - }, - { - value: 'STARTTLS', - display_name: defineMessage({id: 'admin.connectionSecurityStart', defaultMessage: 'STARTTLS'}), - }, - ], - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'bool', - key: 'LdapSettings.SkipCertificateVerification', - label: defineMessage({id: 'admin.ldap.skipCertificateVerification', defaultMessage: 'Skip Certificate Verification:'}), - help_text: defineMessage({id: 'admin.ldap.skipCertificateVerificationDesc', defaultMessage: 'Skips the certificate verification step for TLS or STARTTLS connections. Skipping certificate verification is not recommended for production environments where TLS is required.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.stateIsFalse('LdapSettings.ConnectionSecurity'), - ), - }, - { - type: 'fileupload', - key: 'LdapSettings.PrivateKeyFile', - label: defineMessage({id: 'admin.ldap.privateKeyFileTitle', defaultMessage: 'Private Key:'}), - help_text: defineMessage({id: 'admin.ldap.privateKeyFileFileDesc', defaultMessage: 'The private key file for TLS Certificate. If using TLS client certificates as primary authentication mechanism. This will be provided by your LDAP Authentication Provider.'}), - remove_help_text: defineMessage({id: 'admin.ldap.privateKeyFileFileRemoveDesc', defaultMessage: 'Remove the private key file for TLS Certificate.'}), - remove_button_text: defineMessage({id: 'admin.ldap.remove.privKey', defaultMessage: 'Remove TLS Certificate Private Key'}), - removing_text: defineMessage({id: 'admin.ldap.removing.privKey', defaultMessage: 'Removing Private Key...'}), - uploading_text: defineMessage({id: 'admin.ldap.uploading.privateKey', defaultMessage: 'Uploading Private Key...'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - fileType: '.key', - upload_action: uploadPrivateLdapCertificate, - remove_action: removePrivateLdapCertificate, - }, - { - type: 'fileupload', - key: 'LdapSettings.PublicCertificateFile', - label: defineMessage({id: 'admin.ldap.publicCertificateFileTitle', defaultMessage: 'Public Certificate:'}), - help_text: defineMessage({id: 'admin.ldap.publicCertificateFileDesc', defaultMessage: 'The public certificate file for TLS Certificate. If using TLS client certificates as primary authentication mechanism. This will be provided by your LDAP Authentication Provider.'}), - remove_help_text: defineMessage({id: 'admin.ldap.publicCertificateFileRemoveDesc', defaultMessage: 'Remove the public certificate file for TLS Certificate.'}), - remove_button_text: defineMessage({id: 'admin.ldap.remove.sp_certificate', defaultMessage: 'Remove Service Provider Certificate'}), - removing_text: defineMessage({id: 'admin.ldap.removing.certificate', defaultMessage: 'Removing Certificate...'}), - uploading_text: defineMessage({id: 'admin.ldap.uploading.certificate', defaultMessage: 'Uploading Certificate...'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - fileType: '.crt,.cer', - upload_action: uploadPublicLdapCertificate, - remove_action: removePublicLdapCertificate, - }, - { - type: 'text', - key: 'LdapSettings.BindUsername', - label: defineMessage({id: 'admin.ldap.bindUserTitle', defaultMessage: 'Bind Username:'}), - help_text: defineMessage({id: 'admin.ldap.bindUserDesc', defaultMessage: 'The username used to perform the AD/LDAP search. This should typically be an account created specifically for use with Mattermost. It should have access limited to read the portion of the AD/LDAP tree specified in the Base DN field.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.BindPassword', - label: defineMessage({id: 'admin.ldap.bindPwdTitle', defaultMessage: 'Bind Password:'}), - help_text: defineMessage({id: 'admin.ldap.bindPwdDesc', defaultMessage: 'Password of the user given in "Bind Username".'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - ], - }, - { - key: 'admin.authentication.ldap.dn_and_filters', - title: 'Base DN & Filters', - settings: [ - { - type: 'text', - key: 'LdapSettings.BaseDN', - label: defineMessage({id: 'admin.ldap.baseTitle', defaultMessage: 'Base DN:'}), - help_text: defineMessage({id: 'admin.ldap.baseDesc', defaultMessage: 'The Base DN is the Distinguished Name of the location where Mattermost should start its search for user and group objects in the AD/LDAP tree.'}), - placeholder: defineMessage({id: 'admin.ldap.baseEx', defaultMessage: 'E.g.: "ou=Unit Name,dc=corp,dc=example,dc=com"'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.UserFilter', - label: defineMessage({id: 'admin.ldap.userFilterTitle', defaultMessage: 'User Filter:'}), - help_text: defineMessage({id: 'admin.ldap.userFilterDisc', defaultMessage: '(Optional) Enter an AD/LDAP filter to use when searching for user objects. Only the users selected by the query will be able to access Mattermost. For Active Directory, the query to filter out disabled users is (&(objectCategory=Person)(!(UserAccountControl:1.2.840.113556.1.4.803:=2))).'}), - placeholder: defineMessage({id: 'admin.ldap.userFilterEx', defaultMessage: 'Ex. "(objectClass=user)"'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.GroupFilter', - label: defineMessage({id: 'admin.ldap.groupFilterTitle', defaultMessage: 'Group Filter:'}), - help_text: defineMessage({id: 'admin.ldap.groupFilterFilterDesc', defaultMessage: '(Optional) Enter an AD/LDAP filter to use when searching for group objects. Only the groups selected by the query will be available to Mattermost. From [User Management > Groups]({siteURL}/admin_console/user_management/groups), select which AD/LDAP groups should be linked and configured.'}), - help_text_markdown: true, - help_text_values: {siteURL: getSiteURL()}, - placeholder: defineMessage({id: 'admin.ldap.groupFilterEx', defaultMessage: 'E.g.: "(objectClass=group)"'}), - isHidden: it.not(it.licensedForFeature('LDAPGroups')), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - }, - { - type: 'bool', - key: 'LdapSettings.EnableAdminFilter', - label: defineMessage({id: 'admin.ldap.enableAdminFilterTitle', defaultMessage: 'Enable Admin Filter:'}), - isDisabled: it.any( - it.not(it.isSystemAdmin), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.AdminFilter', - label: defineMessage({id: 'admin.ldap.adminFilterTitle', defaultMessage: 'Admin Filter:'}), - help_text: defineMessage({id: 'admin.ldap.adminFilterFilterDesc', defaultMessage: '(Optional) Enter an AD/LDAP filter to use for designating System Admins. The users selected by the query will have access to your Mattermost server as System Admins. By default, System Admins have complete access to the Mattermost System Console. Existing members that are identified by this attribute will be promoted from member to System Admin upon next login. The next login is based upon Session lengths set in **System Console > Session Lengths**. It is highly recommend to manually demote users to members in **System Console > User Management** to ensure access is restricted immediately. Note: If this filter is removed/changed, System Admins that were promoted via this filter will be demoted to members and will not retain access to the System Console. When this filter is not in use, System Admins can be manually promoted/demoted in **System Console > User Management**.'}), - help_text_markdown: true, - placeholder: defineMessage({id: 'admin.ldap.adminFilterEx', defaultMessage: 'E.g.: "(objectClass=user)"'}), - isDisabled: it.any( - it.not(it.isSystemAdmin), - it.stateIsFalse('LdapSettings.EnableAdminFilter'), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.GuestFilter', - label: defineMessage({id: 'admin.ldap.guestFilterTitle', defaultMessage: 'Guest Filter:'}), - help_text: defineMessage({id: 'admin.ldap.guestFilterFilterDesc', defaultMessage: '(Optional) Requires Guest Access to be enabled before being applied. Enter an AD/LDAP filter to use when searching for guest objects. Only the users selected by the query will be able to access Mattermost as Guests. Guests are prevented from accessing teams or channels upon logging in until they are assigned a team and at least one channel. Note: If this filter is removed/changed, active guests will not be promoted to a member and will retain their Guest role. Guests can be promoted in **System Console > User Management**. Existing members that are identified by this attribute as a guest will be demoted from a member to a guest when they are asked to login next. The next login is based upon Session lengths set in **System Console > Session Lengths**. It is highly recommend to manually demote users to guests in **System Console > User Management ** to ensure access is restricted immediately.'}), - help_text_markdown: true, - placeholder: defineMessage({id: 'admin.ldap.guestFilterEx', defaultMessage: 'E.g.: "(objectClass=user)"'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.configIsFalse('GuestAccountsSettings', 'Enable'), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - ], - }, - { - key: 'admin.authentication.ldap.account_synchronization', - title: 'Account Synchronization', - settings: [ - { - type: 'text', - key: 'LdapSettings.IdAttribute', - label: defineMessage({id: 'admin.ldap.idAttrTitle', defaultMessage: 'ID Attribute: '}), - placeholder: defineMessage({id: 'admin.ldap.idAttrEx', defaultMessage: 'E.g.: "objectGUID" or "uid"'}), - help_text: defineMessage({id: 'admin.ldap.idAttrDesc', defaultMessage: "The attribute in the AD/LDAP server used as a unique identifier in Mattermost. It should be an AD/LDAP attribute with a value that does not change such as `uid` for LDAP or `objectGUID` for Active Directory. If a user's ID Attribute changes, it will create a new Mattermost account unassociated with their old one. If you need to change this field after users have already logged in, use the mattermost ldap idmigrate CLI tool."}), - help_text_markdown: false, - help_text_values: { - link: (msg: string) => ( - - {msg} - - ), - }, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateEquals('LdapSettings.Enable', false), - it.stateEquals('LdapSettings.EnableSync', false), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.LoginIdAttribute', - label: defineMessage({id: 'admin.ldap.loginAttrTitle', defaultMessage: 'Login ID Attribute: '}), - placeholder: defineMessage({id: 'admin.ldap.loginIdAttrEx', defaultMessage: 'E.g.: "sAMAccountName"'}), - help_text: defineMessage({id: 'admin.ldap.loginAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used to log in to Mattermost. Normally this attribute is the same as the "Username Attribute" field above. If your team typically uses domain/username to log in to other services with AD/LDAP, you may enter domain/username in this field to maintain consistency between sites.'}), - help_text_markdown: false, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.UsernameAttribute', - label: defineMessage({id: 'admin.ldap.usernameAttrTitle', defaultMessage: 'Username Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.usernameAttrEx', defaultMessage: 'E.g.: "sAMAccountName"'}), - help_text: defineMessage({id: 'admin.ldap.usernameAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used to populate the username field in Mattermost. This may be the same as the Login ID Attribute.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.EmailAttribute', - label: defineMessage({id: 'admin.ldap.emailAttrTitle', defaultMessage: 'Email Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.emailAttrEx', defaultMessage: 'E.g.: "mail" or "userPrincipalName"'}), - help_text: defineMessage({id: 'admin.ldap.emailAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used to populate the email address field in Mattermost.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.FirstNameAttribute', - label: defineMessage({id: 'admin.ldap.firstnameAttrTitle', defaultMessage: 'First Name Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.firstnameAttrEx', defaultMessage: 'E.g.: "givenName"'}), - help_text: defineMessage({id: 'admin.ldap.firstnameAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the first name of users in Mattermost. When set, users cannot edit their first name, since it is synchronized with the LDAP server. When left blank, users can set their first name in Account Menu > Account Settings > Profile.'}), - help_text_values: { - strong: (msg: string) => {msg}, - }, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.LastNameAttribute', - label: defineMessage({id: 'admin.ldap.lastnameAttrTitle', defaultMessage: 'Last Name Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.lastnameAttrEx', defaultMessage: 'E.g.: "sn"'}), - help_text: defineMessage({id: 'admin.ldap.lastnameAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the last name of users in Mattermost. When set, users cannot edit their last name, since it is synchronized with the LDAP server. When left blank, users can set their last name in Account Menu > Account Settings > Profile.'}), - help_text_values: { - strong: (msg: string) => {msg}, - }, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.NicknameAttribute', - label: defineMessage({id: 'admin.ldap.nicknameAttrTitle', defaultMessage: 'Nickname Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.nicknameAttrEx', defaultMessage: 'E.g.: "nickname"'}), - help_text: defineMessage({id: 'admin.ldap.nicknameAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the nickname of users in Mattermost. When set, users cannot edit their nickname, since it is synchronized with the LDAP server. When left blank, users can set their nickname in Account Menu > Account Settings > Profile.'}), - help_text_values: { - strong: (msg: string) => {msg}, - }, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.PositionAttribute', - label: defineMessage({id: 'admin.ldap.positionAttrTitle', defaultMessage: 'Position Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.positionAttrEx', defaultMessage: 'E.g.: "title"'}), - help_text: defineMessage({id: 'admin.ldap.positionAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the position field in Mattermost. When set, users cannot edit their position, since it is synchronized with the LDAP server. When left blank, users can set their position in Account Menu > Account Settings > Profile.'}), - help_text_values: { - strong: (msg: string) => {msg}, - }, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'text', - key: 'LdapSettings.PictureAttribute', - label: defineMessage({id: 'admin.ldap.pictureAttrTitle', defaultMessage: 'Profile Picture Attribute:'}), - placeholder: defineMessage({id: 'admin.ldap.pictureAttrEx', defaultMessage: 'E.g.: "thumbnailPhoto" or "jpegPhoto"'}), - help_text: defineMessage({id: 'admin.ldap.pictureAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the profile picture in Mattermost.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'custom', - key: 'LdapSettings.CustomProfileAttributes', - component: CustomProfileAttributes, - isHidden: it.not(it.all( - it.minLicenseTier(LicenseSkus.Enterprise), - it.configIsTrue('FeatureFlags', 'CustomProfileAttributes'), - )), - }, - ], - }, - { - key: 'admin.authentication.ldap.group_synchronization', - title: 'Group Synchronization', - settings: [ - { - type: 'text', - key: 'LdapSettings.GroupDisplayNameAttribute', - label: defineMessage({id: 'admin.ldap.groupDisplayNameAttributeTitle', defaultMessage: 'Group Display Name Attribute:'}), - help_text: defineMessage({id: 'admin.ldap.groupDisplayNameAttributeDesc', defaultMessage: 'The attribute in the AD/LDAP server used to populate the group display names.'}), - placeholder: defineMessage({id: 'admin.ldap.groupDisplayNameAttributeEx', defaultMessage: 'E.g.: "cn"'}), - isHidden: it.not(it.licensedForFeature('LDAPGroups')), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - }, - { - type: 'text', - key: 'LdapSettings.GroupIdAttribute', - label: defineMessage({id: 'admin.ldap.groupIdAttributeTitle', defaultMessage: 'Group ID Attribute:'}), - help_text: defineMessage({id: 'admin.ldap.groupIdAttributeDesc', defaultMessage: 'The attribute in the AD/LDAP server used as a unique identifier for Groups. This should be a AD/LDAP attribute with a value that does not change such as `entryUUID` for LDAP or `objectGUID` for Active Directory.'}), - help_text_markdown: true, - placeholder: defineMessage({id: 'admin.ldap.groupIdAttributeEx', defaultMessage: 'E.g.: "objectGUID" or "entryUUID"'}), - isHidden: it.not(it.licensedForFeature('LDAPGroups')), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - }, - ], - }, - { - key: 'admin.authentication.ldap.synchronization_performance', - title: 'Synchronization Performance', - settings: [ - { - type: 'number', - key: 'LdapSettings.SyncIntervalMinutes', - label: defineMessage({id: 'admin.ldap.syncIntervalTitle', defaultMessage: 'Synchronization Interval (minutes):'}), - help_text: defineMessage({id: 'admin.ldap.syncIntervalHelpText', defaultMessage: 'AD/LDAP Synchronization updates Mattermost user information to reflect updates on the AD/LDAP server. For example, when a user\'s name changes on the AD/LDAP server, the change updates in Mattermost when synchronization is performed. Accounts removed from or disabled in the AD/LDAP server have their Mattermost accounts set to "Inactive" and have their account sessions revoked. Mattermost performs synchronization on the interval entered. For example, if 60 is entered, Mattermost synchronizes every 60 minutes.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'bool', - key: 'LdapSettings.ReAddRemovedMembers', - label: defineMessage({id: 'admin.ldap.reAddRemovedMembersTitle', defaultMessage: 'Re-add removed members on sync:'}), - help_text: defineMessage({id: 'admin.ldap.reAddRemovedMembersDesc', defaultMessage: 'When enabled, members who were previously removed from group-synced teams or channels will be re-added during LDAP synchronization if they are still a member of the LDAP group.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'number', - key: 'LdapSettings.MaxPageSize', - label: defineMessage({id: 'admin.ldap.maxPageSizeTitle', defaultMessage: 'Maximum Page Size:'}), - placeholder: defineMessage({id: 'admin.ldap.maxPageSizeEx', defaultMessage: 'E.g.: "2000"'}), - help_text: defineMessage({id: 'admin.ldap.maxPageSizeHelpText', defaultMessage: 'The maximum number of users the Mattermost server will request from the AD/LDAP server at one time. 0 is unlimited.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'number', - key: 'LdapSettings.QueryTimeout', - label: defineMessage({id: 'admin.ldap.queryTitle', defaultMessage: 'Query Timeout (seconds):'}), - placeholder: defineMessage({id: 'admin.ldap.queryEx', defaultMessage: 'E.g.: "60"'}), - help_text: defineMessage({id: 'admin.ldap.queryDesc', defaultMessage: 'The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server.'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - { - type: 'button', - action: ldapTest, - key: 'LdapSettings.LdapTest', - label: defineMessage({id: 'admin.ldap.ldap_test_button', defaultMessage: 'AD/LDAP Test'}), - help_text: defineMessage({id: 'admin.ldap.testHelpText', defaultMessage: 'Tests if the Mattermost server can connect to the AD/LDAP server specified. Please review "System Console > Logs" and documentation to troubleshoot errors.'}), - help_text_values: { - link: (msg: string) => ( - - {msg} - - ), - }, - help_text_markdown: false, - error_message: defineMessage({id: 'admin.ldap.testFailure', defaultMessage: 'AD/LDAP Test Failure: {error}'}), - success_message: defineMessage({id: 'admin.ldap.testSuccess', defaultMessage: 'AD/LDAP Test Successful'}), - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.all( - it.stateIsFalse('LdapSettings.Enable'), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - ), - }, - ], - }, - { - key: 'admin.authentication.ldap.synchronization_history', - title: 'Synchronization History', - subtitle: 'See the table below for the status of each synchronization', - settings: [ - { - type: 'jobstable', - job_type: Constants.JobTypes.LDAP_SYNC, - label: defineMessage({id: 'admin.ldap.sync_button', defaultMessage: 'AD/LDAP Synchronize Now'}), - help_text: defineMessage({id: 'admin.ldap.syncNowHelpText', defaultMessage: 'Initiates an AD/LDAP synchronization immediately. See the table below for status of each synchronization. Please review "System Console > Logs" and documentation to troubleshoot errors.'}), - help_text_markdown: false, - help_text_values: { - link: (msg: string) => ( - - {msg} - - ), - }, - isDisabled: it.any( - it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), - it.stateIsFalse('LdapSettings.EnableSync'), - ), - render_job: (job: Job) => { - if (job.status === 'pending') { - return {'--'}; - } - - let ldapUsers = 0; - let deleteCount = 0; - let updateCount = 0; - let linkedLdapGroupsCount; // Deprecated. - let totalLdapGroupsCount = 0; - let groupDeleteCount = 0; - let groupMemberDeleteCount = 0; - let groupMemberAddCount = 0; - - if (job && job.data) { - if (job.data.ldap_users_count && job.data.ldap_users_count.length > 0) { - ldapUsers = job.data.ldap_users_count; - } - - if (job.data.delete_count && job.data.delete_count.length > 0) { - deleteCount = job.data.delete_count; - } - - if (job.data.update_count && job.data.update_count.length > 0) { - updateCount = job.data.update_count; - } - - // Deprecated groups count representing the number of linked LDAP groups. - if (job.data.ldap_groups_count) { - linkedLdapGroupsCount = job.data.ldap_groups_count; - } - - // Groups count representing the total number of LDAP groups available based on - // the configured based DN and groups filter. - if (job.data.total_ldap_groups_count) { - totalLdapGroupsCount = job.data.total_ldap_groups_count; - } - - if (job.data.group_delete_count) { - groupDeleteCount = job.data.group_delete_count; - } - - if (job.data.group_member_delete_count) { - groupMemberDeleteCount = job.data.group_member_delete_count; - } - - if (job.data.group_member_add_count) { - groupMemberAddCount = job.data.group_member_add_count; - } - } - - return ( - - -
    - {updateCount > 0 && -
  • - -
  • - } - {deleteCount > 0 && -
  • - -
  • - } - {groupDeleteCount > 0 && -
  • - -
  • - } - {groupMemberDeleteCount > 0 && -
  • - -
  • - } - {groupMemberAddCount > 0 && -
  • - -
  • - } -
-
- ); - }, - }, - ], - }, - ], + id: 'LdapWizard', + component: LDAPWizard, }, - restrictedIndicator: getRestrictedIndicator(), }, ldap_feature_discovery: { url: 'authentication/ldap', @@ -4943,7 +4143,7 @@ const AdminDefinition: AdminDefinitionType = { id: 'GitLabSettings', name: defineMessage({id: 'admin.authentication.gitlab', defaultMessage: 'GitLab'}), onConfigLoad: (config) => { - const newState: {'GitLabSettings.Url'?: string} = {}; + const newState: { 'GitLabSettings.Url'?: string } = {}; newState['GitLabSettings.Url'] = config.GitLabSettings?.UserAPIEndpoint?.replace('/api/v4/user', ''); return newState; }, @@ -5056,7 +4256,7 @@ const AdminDefinition: AdminDefinitionType = { id: 'OAuthSettings', name: defineMessage({id: 'admin.authentication.oauth', defaultMessage: 'OAuth 2.0'}), onConfigLoad: (config) => { - const newState: {oauthType?: string; 'GitLabSettings.Url'?: string} = {}; + const newState: { oauthType?: string; 'GitLabSettings.Url'?: string } = {}; if (config.GitLabSettings?.Enable) { newState.oauthType = Constants.GITLAB_SERVICE; } @@ -5380,7 +4580,7 @@ const AdminDefinition: AdminDefinitionType = { id: 'OpenIdSettings', name: defineMessage({id: 'admin.authentication.openid', defaultMessage: 'OpenID Connect'}), onConfigLoad: (config) => { - const newState: {openidType?: string; 'GitLabSettings.Url'?: string} = {}; + const newState: { openidType?: string; 'GitLabSettings.Url'?: string } = {}; if (config.Office365Settings?.Enable) { newState.openidType = Constants.OFFICE365_SERVICE; } diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx new file mode 100644 index 0000000000..41026bd4d6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {defineMessage, type MessageDescriptor} from 'react-intl'; + +import type {CloudState, Product} from '@mattermost/types/cloud'; +import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; + +import RestrictedIndicator from 'components/widgets/menu/menu_items/restricted_indicator'; + +import {CloudProducts, getLicenseTier, LicenseSkus} from 'utils/constants'; +import {isCloudLicense} from 'utils/license_utils'; + +import type {Check, ConsoleAccess} from './types'; +import ValidationResult from './validation'; + +export const it = { + not: (func: Check) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => { + return typeof func === 'function' ? !func(config, state, license, enterpriseReady, consoleAccess, cloud, isSystemAdmin) : !func; + }, + all: (...funcs: Check[]) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => { + for (const func of funcs) { + if (typeof func === 'function' ? !func(config, state, license, enterpriseReady, consoleAccess, cloud, isSystemAdmin) : !func) { + return false; + } + } + return true; + }, + any: (...funcs: Check[]) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => { + for (const func of funcs) { + if (typeof func === 'function' ? func(config, state, license, enterpriseReady, consoleAccess, cloud, isSystemAdmin) : func) { + return true; + } + } + return false; + }, + stateMatches: (key: string, regex: RegExp) => (config: Partial, state: any) => state[key].match(regex), + stateEquals: (key: string, value: any) => (config: Partial, state: any) => state[key] === value, + stateIsTrue: (key: string) => (config: Partial, state: any) => Boolean(state[key]), + stateIsFalse: (key: string) => (config: Partial, state: any) => !state[key], + configIsTrue: (group: keyof Partial, setting: string) => (config: Partial) => Boolean((config[group] as any)?.[setting]), + configIsFalse: (group: keyof Partial, setting: string) => (config: Partial) => !(config[group] as any)?.[setting], + configContains: (group: keyof Partial, setting: string, word: string) => (config: Partial) => Boolean((config[group] as any)?.[setting]?.includes(word)), + enterpriseReady: (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean) => Boolean(enterpriseReady), + licensed: (config: Partial, state: any, license?: ClientLicense) => license?.IsLicensed === 'true', + cloudLicensed: (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && isCloudLicense(license)), + licensedForFeature: (feature: string) => (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && license[feature] === 'true'), + licensedForSku: (skuName: string) => (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && license.SkuShortName === skuName), + minLicenseTier: (skuName: string) => (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && getLicenseTier(license.SkuShortName) >= getLicenseTier(skuName)), + licensedForCloudStarter: (config: Partial, state: any, license?: ClientLicense) => Boolean(license?.IsLicensed && isCloudLicense(license) && license.SkuShortName === LicenseSkus.Starter), + hidePaymentInfo: (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => { + if (!cloud) { + return true; + } + const productId = cloud?.subscription?.product_id; + if (!productId) { + return false; + } + return cloud?.subscription?.is_free_trial === 'true'; + }, + userHasReadPermissionOnResource: (key: string) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => (consoleAccess?.read as any)?.[key], + userHasReadPermissionOnSomeResources: (key: string | { [key: string]: string }) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => Object.values(key).some((resource) => (consoleAccess?.read as any)?.[resource]), + userHasWritePermissionOnResource: (key: string) => (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => (consoleAccess?.write as any)?.[key], + isSystemAdmin: (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => Boolean(isSystemAdmin), +}; + +export const validators = { + isRequired: (text: MessageDescriptor | string) => (value: string) => new ValidationResult(Boolean(value), text), + minValue: (min: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value >= min), text), + maxValue: (max: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value <= max), text), +}; + +export const usesLegacyOauth = (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => { + if (!config.GitLabSettings || !config.GoogleSettings || !config.Office365Settings) { + return false; + } + + return it.any( + it.all( + it.not(it.configContains('GitLabSettings', 'Scope', 'openid')), + it.any( + it.configIsTrue('GitLabSettings', 'Id'), + it.configIsTrue('GitLabSettings', 'Secret'), + ), + ), + it.all( + it.not(it.configContains('GoogleSettings', 'Scope', 'openid')), + it.any( + it.configIsTrue('GoogleSettings', 'Id'), + it.configIsTrue('GoogleSettings', 'Secret'), + ), + ), + it.all( + it.not(it.configContains('Office365Settings', 'Scope', 'openid')), + it.any( + it.configIsTrue('Office365Settings', 'Id'), + it.configIsTrue('Office365Settings', 'Secret'), + ), + ), + )(config, state, license, enterpriseReady, consoleAccess, cloud); +}; + +export const getRestrictedIndicator = (displayBlocked = false, minimumPlanRequiredForFeature = LicenseSkus.Professional) => ({ + value: (cloud: CloudState) => ( + + ), + shouldDisplay: (license: ClientLicense, subscriptionProduct: Product | undefined) => displayBlocked || (isCloudLicense(license) && subscriptionProduct?.sku === CloudProducts.STARTER), +}); diff --git a/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx b/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx new file mode 100644 index 0000000000..835828701c --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx @@ -0,0 +1,798 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, defineMessage} from 'react-intl'; + +import type {Job} from '@mattermost/types/jobs'; + +import {RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole'; + +import { + ldapTestAttributes, + ldapTestConnection, + ldapTestFilters, + ldapTestGroupAttributes, + removePrivateLdapCertificate, + removePublicLdapCertificate, + uploadPrivateLdapCertificate, + uploadPublicLdapCertificate, +} from 'actions/admin_actions'; + +import ExternalLink from 'components/external_link'; + +import Constants, {DocLinks, LicenseSkus} from 'utils/constants'; +import {getSiteURL} from 'utils/url'; + +import * as DefinitionConstants from './admin_definition_constants'; +import {it} from './admin_definition_helpers'; +import CustomProfileAttributes from './custom_profile_attributes/custom_profile_attributes'; +import type {LDAPAdminDefinitionConfigSchemaSettings} from './ldap_wizard/ldap_wizard'; + +const ASTERISK_PASSWORD_PATTERN = /^\*+$/; + +export const ldapWizardAdminDefinition: LDAPAdminDefinitionConfigSchemaSettings = { + id: 'LdapSettings', + name: defineMessage({id: 'admin.authentication.ldap.wizard', defaultMessage: 'AD/LDAP Wizard'}), + sections: [{ + key: 'admin.authentication.ldap.connection', + title: 'Connection Settings', + subtitle: 'Connection and security level to your AD/LDAP server.', + settings: [ + { + type: 'bool', + key: 'LdapSettings.Enable', + label: defineMessage({id: 'admin.ldap.enableTitle', defaultMessage: 'Enable sign-in with AD/LDAP:'}), + help_text: defineMessage({id: 'admin.ldap.enableDesc', defaultMessage: 'When true, Mattermost allows login using AD/LDAP'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + }, + { + type: 'bool', + key: 'LdapSettings.EnableSync', + label: defineMessage({id: 'admin.ldap.enableSyncTitle', defaultMessage: 'Enable Synchronization with AD/LDAP:'}), + help_text: defineMessage({id: 'admin.ldap.enableSyncDesc', defaultMessage: 'When true, Mattermost periodically synchronizes users from AD/LDAP. When false, user attributes are updated from AD/LDAP during user login only.'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + }, + { + type: 'text', + key: 'LdapSettings.LoginFieldName', + label: defineMessage({id: 'admin.ldap.loginNameTitle', defaultMessage: 'Login Field Name:'}), + placeholder: defineMessage({id: 'admin.ldap.loginNameEx', defaultMessage: 'E.g.: "AD/LDAP Username"'}), + help_text: defineMessage({id: 'admin.ldap.loginNameDesc', defaultMessage: 'The placeholder text that appears in the login field on the login page. Defaults to "AD/LDAP Username".'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.LdapServer', + label: defineMessage({id: 'admin.ldap.serverTitle', defaultMessage: 'AD/LDAP Server:'}), + help_text: defineMessage({id: 'admin.ldap.serverDesc', defaultMessage: 'The domain or IP address of AD/LDAP server.'}), + placeholder: defineMessage({id: 'admin.ldap.serverEx', defaultMessage: 'E.g.: "10.0.0.23"'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'number', + key: 'LdapSettings.LdapPort', + label: defineMessage({id: 'admin.ldap.portTitle', defaultMessage: 'AD/LDAP Port:'}), + help_text: defineMessage({id: 'admin.ldap.portDesc', defaultMessage: 'The port Mattermost will use to connect to the AD/LDAP server. Default is 389.'}), + placeholder: defineMessage({id: 'admin.ldap.portEx', defaultMessage: 'E.g.: "389"'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.BindUsername', + label: defineMessage({id: 'admin.ldap.bindUserTitle', defaultMessage: 'Bind Username:'}), + help_text: defineMessage({id: 'admin.ldap.bindUserDesc', defaultMessage: 'The username used to perform the AD/LDAP search.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.bindUserDescHover', defaultMessage: 'This should typically be an account created specifically for use with Mattermost. It should have access limited to read the portion of the AD/LDAP tree specified in the Base DN field.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.BindPassword', + label: defineMessage({id: 'admin.ldap.bindPwdTitle', defaultMessage: 'Bind Password:'}), + help_text: defineMessage({id: 'admin.ldap.bindPwdDesc', defaultMessage: 'Password of the user given in "Bind Username".'}), + onConfigSave: (value: string) => { + // If the password is just asterisks (placeholder from server), don't send it + if (typeof value === 'string' && ASTERISK_PASSWORD_PATTERN.test(value)) { + return undefined; + } + return value; + }, + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'dropdown', + key: 'LdapSettings.ConnectionSecurity', + label: defineMessage({id: 'admin.connectionSecurityTitle', defaultMessage: 'Connection Security:'}), + help_text: DefinitionConstants.CONNECTION_SECURITY_HELP_TEXT_LDAP, + options: [ + { + value: '', + display_name: defineMessage({id: 'admin.connectionSecurityNone', defaultMessage: 'None'}), + }, + { + value: 'TLS', + display_name: defineMessage({id: 'admin.connectionSecurityTls', defaultMessage: 'TLS (Recommended)'}), + }, + { + value: 'STARTTLS', + display_name: defineMessage({id: 'admin.connectionSecurityStart', defaultMessage: 'STARTTLS'}), + }, + ], + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'bool', + key: 'LdapSettings.SkipCertificateVerification', + label: defineMessage({id: 'admin.ldap.skipCertificateVerification', defaultMessage: 'Skip Certificate Verification:'}), + help_text: defineMessage({id: 'admin.ldap.skipCertificateVerificationDesc', defaultMessage: 'Skips the certificate verification step for TLS or STARTTLS connections.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.skipCertificateVerificationDescHover', defaultMessage: 'Skipping certificate verification is not recommended for production environments where TLS is required.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.stateIsFalse('LdapSettings.ConnectionSecurity'), + ), + }, + { + type: 'fileupload', + key: 'LdapSettings.PrivateKeyFile', + label: defineMessage({id: 'admin.ldap.privateKeyFileTitle', defaultMessage: 'Private Key:'}), + help_text: defineMessage({id: 'admin.ldap.privateKeyFileFileDesc', defaultMessage: 'The private key file for TLS Certificate.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.privateKeyFileFileDescHover', defaultMessage: 'If using TLS client certificates as primary authentication mechanism. This will be provided by your LDAP Authentication Provider.'}), + remove_help_text: defineMessage({id: 'admin.ldap.privateKeyFileFileRemoveDesc', defaultMessage: 'Remove the private key file for TLS Certificate.'}), + remove_button_text: defineMessage({id: 'admin.ldap.remove.privKey', defaultMessage: 'Remove TLS Certificate Private Key'}), + removing_text: defineMessage({id: 'admin.ldap.removing.privKey', defaultMessage: 'Removing Private Key...'}), + uploading_text: defineMessage({id: 'admin.ldap.uploading.privateKey', defaultMessage: 'Uploading Private Key...'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + fileType: '.key', + upload_action: uploadPrivateLdapCertificate, + remove_action: removePrivateLdapCertificate, + }, + { + type: 'fileupload', + key: 'LdapSettings.PublicCertificateFile', + label: defineMessage({id: 'admin.ldap.publicCertificateFileTitle', defaultMessage: 'Public Certificate:'}), + help_text: defineMessage({id: 'admin.ldap.publicCertificateFileDesc', defaultMessage: 'The public certificate file for TLS Certificate.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.publicCertificateFileDescHover', defaultMessage: 'If using TLS client certificates as primary authentication mechanism. This will be provided by your LDAP Authentication Provider.'}), + remove_help_text: defineMessage({id: 'admin.ldap.publicCertificateFileRemoveDesc', defaultMessage: 'Remove the public certificate file for TLS Certificate.'}), + remove_button_text: defineMessage({id: 'admin.ldap.remove.sp_certificate', defaultMessage: 'Remove Service Provider Certificate'}), + removing_text: defineMessage({id: 'admin.ldap.removing.certificate', defaultMessage: 'Removing Certificate...'}), + uploading_text: defineMessage({id: 'admin.ldap.uploading.certificate', defaultMessage: 'Uploading Certificate...'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + fileType: '.crt,.cer', + upload_action: uploadPublicLdapCertificate, + remove_action: removePublicLdapCertificate, + }, + { + type: 'number', + key: 'LdapSettings.MaximumLoginAttempts', + label: defineMessage({id: 'admin.ldap.maximumLoginAttemptsTitle', defaultMessage: 'Maximum Login Attempts:'}), + help_text: defineMessage({id: 'admin.ldap.maximumLoginAttemptsDesc', defaultMessage: 'The maximum number of login attempts before the Mattermost account is locked.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.maximumLoginAttemptsDescHover', defaultMessage: 'You can unlock the account in system console on the users page. Setting this value lower than your LDAP maximum login attempts ensures that the users won\'t be locked out of your LDAP server because of failed login attempts in Mattermost.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'button', + action: ldapTestConnection, + key: 'LdapSettings.TestConnection', + label: defineMessage({id: 'admin.ldap.testConnectionTitle', defaultMessage: 'Test Connection'}), + help_text: defineMessage({id: 'admin.ldap.testHelpText', defaultMessage: 'Tests if the Mattermost server can connect to the AD/LDAP server specified. Please review "System Console > Logs" and documentation to troubleshoot errors.'}), + help_text_values: { + link: (msg: string) => ( + + {msg} + + ), + }, + help_text_markdown: false, + error_message: defineMessage({id: 'admin.ldap.testConnectionFailure', defaultMessage: 'Test Connection Failure: {error}'}), + success_message: defineMessage({id: 'admin.ldap.testConnectionSuccess', defaultMessage: 'Test Connection Successful'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + ], + }, + { + key: 'admin.authentication.ldap.dn_and_filters', + title: 'User Filters', + subtitle: 'Tell Mattermost how to identify your users within LDAP', + settings: [ + { + type: 'text', + key: 'LdapSettings.BaseDN', + label: defineMessage({id: 'admin.ldap.baseTitle', defaultMessage: 'Base DN:'}), + help_text: defineMessage({id: 'admin.ldap.baseDesc', defaultMessage: 'The Base DN is the Distinguished Name of the location where Mattermost should start its search for user and group objects in the AD/LDAP tree.'}), + placeholder: defineMessage({id: 'admin.ldap.baseEx', defaultMessage: 'E.g.: "ou=Unit Name,dc=corp,dc=example,dc=com"'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.UserFilter', + label: defineMessage({id: 'admin.ldap.userFilterTitle', defaultMessage: 'User Filter:'}), + help_text: defineMessage({id: 'admin.ldap.userFilterDisc', defaultMessage: '(Optional) Enter an AD/LDAP filter to use when searching for user objects. When blank, defaults to the ID Attribute.\nFor Active Directory, the query to filter out disabled users is\n(&(objectCategory=Person)(!(UserAccountControl:1.2.840.113556.1.4.803:=2))).'}), + help_text_more_info: defineMessage({id: 'admin.ldap.userFilterDiscHover', defaultMessage: 'Only the users selected by the query will be able to access Mattermost.'}), + placeholder: defineMessage({id: 'admin.ldap.userFilterEx', defaultMessage: 'Ex. "(objectClass=user)"'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'expandable_setting', + key: 'LdapSettings.AdditionalFilters', + label: defineMessage({id: 'admin.ldap.configure_additional_filters', defaultMessage: 'Configure additional filters'}), + settings: [ + { + type: 'text', + key: 'LdapSettings.GroupFilter', + label: defineMessage({id: 'admin.ldap.groupFilterTitle', defaultMessage: 'Group Filter:'}), + help_text: defineMessage({id: 'admin.ldap.groupFilterFilterDesc', defaultMessage: '(Optional) Enter an AD/LDAP filter to use when searching for group objects. From [User Management > Groups]({siteURL}/admin_console/user_management/groups), select which AD/LDAP groups should be linked and configured.'}), + help_text_markdown: true, + help_text_values: {siteURL: getSiteURL()}, + help_text_more_info: defineMessage({id: 'admin.ldap.groupFilterFilterDescHover', defaultMessage: 'Only the groups selected by the query will be available to Mattermost.'}), + placeholder: defineMessage({id: 'admin.ldap.groupFilterEx', defaultMessage: 'E.g.: "(objectClass=group)"'}), + isHidden: it.not(it.licensedForFeature('LDAPGroups')), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + }, + { + type: 'bool', + key: 'LdapSettings.EnableAdminFilter', + label: defineMessage({id: 'admin.ldap.enableAdminFilterTitle', defaultMessage: 'Enable Admin Filter:'}), + isDisabled: it.any( + it.not(it.isSystemAdmin), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.AdminFilter', + label: defineMessage({id: 'admin.ldap.adminFilterTitle', defaultMessage: 'Admin Filter:'}), + help_text: defineMessage({id: 'admin.ldap.adminFilterFilterDesc', defaultMessage: '(Optional) Enter an AD/LDAP filter to use for designating System Admins.'}), + // eslint-disable-next-line formatjs/no-multiple-whitespaces + help_text_more_info: defineMessage({id: 'admin.ldap.adminFilterFilterDescHover', defaultMessage: 'The users selected by the query will have access to your Mattermost server as System Admins. By default, System Admins have complete access to the Mattermost System Console. Existing members that are identified by this attribute will be promoted from member to System Admin upon next login. The next login is based upon Session lengths set in System Console > Session Lengths. It is highly recommend to manually demote users to members in System Console > User Management to ensure access is restricted immediately.\n \nNote: If this filter is removed/changed, System Admins that were promoted via this filter will be demoted to members and will not retain access to the System Console. When this filter is not in use, System Admins can be manually promoted/demoted in System Console > User Management.'}), + placeholder: defineMessage({id: 'admin.ldap.adminFilterEx', defaultMessage: 'E.g.: "(objectClass=user)"'}), + isDisabled: it.any( + it.not(it.isSystemAdmin), + it.stateIsFalse('LdapSettings.EnableAdminFilter'), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.GuestFilter', + label: defineMessage({id: 'admin.ldap.guestFilterTitle', defaultMessage: 'Guest Filter:'}), + help_text: defineMessage({id: 'admin.ldap.guestFilterFilterDesc', defaultMessage: '(Optional) Requires Guest Access to be enabled before being applied. Enter an AD/LDAP filter to use when searching for guest objects.'}), + // eslint-disable-next-line formatjs/no-multiple-whitespaces + help_text_more_info: defineMessage({id: 'admin.ldap.guestFilterFilterDescHover', defaultMessage: 'Only the users selected by the query will be able to access Mattermost as Guests. Guests are prevented from accessing teams or channels upon logging in until they are assigned a team and at least one channel.\n \nNote: If this filter is removed/changed, active guests will not be promoted to a member and will retain their Guest role. Guests can be promoted in System Console > User Management. Existing members that are identified by this attribute as a guest will be demoted from a member to a guest when they are asked to login next. The next login is based upon Session lengths set in System Console > Session Lengths. It is highly recommend to manually demote users to guests in System Console > User Management to ensure access is restricted immediately.'}), + placeholder: defineMessage({id: 'admin.ldap.guestFilterEx', defaultMessage: 'E.g.: "(objectClass=user)"'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.configIsFalse('GuestAccountsSettings', 'Enable'), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + ], + }, + { + type: 'button', + action: ldapTestFilters, + key: 'LdapSettings.TestFilters', + label: defineMessage({id: 'admin.ldap.testFiltersTitle', defaultMessage: 'Test Filters'}), + help_text_markdown: false, + error_message: defineMessage({id: 'admin.ldap.testFiltersFailure', defaultMessage: 'We failed to apply some filters: {error}'}), + success_message: defineMessage({id: 'admin.ldap.testFiltersSuccess', defaultMessage: 'Test Successful'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + ], + }, + { + key: 'admin.authentication.ldap.account_synchronization', + title: 'Synchronise user account properties', + sectionTitle: 'Account sync', + settings: [ + { + type: 'text', + key: 'LdapSettings.IdAttribute', + label: defineMessage({id: 'admin.ldap.idAttrTitle', defaultMessage: 'ID Attribute: '}), + placeholder: defineMessage({id: 'admin.ldap.idAttrEx', defaultMessage: 'E.g.: "objectGUID" or "uid"'}), + help_text: defineMessage({id: 'admin.ldap.idAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used as a unique identifier in Mattermost. If you need to change this field after users have already logged in, use the mattermost ldap idmigrate CLI tool.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.idAttrDescHover', defaultMessage: 'It should be an AD/LDAP attribute with a value that does not change such as uid for LDAP or objectGUID for Active Directory. If a user\'s ID Attribute changes, it will create a new Mattermost account unassociated with their old one.'}), + help_text_markdown: false, + help_text_values: { + link: (msg: string) => ( + + {msg} + + ), + }, + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateEquals('LdapSettings.Enable', false), + it.stateEquals('LdapSettings.EnableSync', false), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.LoginIdAttribute', + label: defineMessage({id: 'admin.ldap.loginAttrTitle', defaultMessage: 'Login ID Attribute: '}), + placeholder: defineMessage({id: 'admin.ldap.loginIdAttrEx', defaultMessage: 'E.g.: "sAMAccountName"'}), + help_text: defineMessage({id: 'admin.ldap.loginAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used to log in to Mattermost.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.loginAttrDescHover', defaultMessage: 'Normally this attribute is the same as the "Username Attribute" field above. If your team typically uses domain/username to log in to other services with AD/LDAP, you may enter domain/username in this field to maintain consistency between sites.'}), + help_text_markdown: false, + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.UsernameAttribute', + label: defineMessage({id: 'admin.ldap.usernameAttrTitle', defaultMessage: 'Username Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.usernameAttrEx', defaultMessage: 'E.g.: "sAMAccountName"'}), + help_text: defineMessage({id: 'admin.ldap.usernameAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used to populate the username field in Mattermost.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.usernameAttrDescHover', defaultMessage: 'This may be the same as the Login ID Attribute.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.EmailAttribute', + label: defineMessage({id: 'admin.ldap.emailAttrTitle', defaultMessage: 'Email Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.emailAttrEx', defaultMessage: 'E.g.: "mail" or "userPrincipalName"'}), + help_text: defineMessage({id: 'admin.ldap.emailAttrDesc', defaultMessage: 'The attribute in the AD/LDAP server used to populate the email address field in Mattermost.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.FirstNameAttribute', + label: defineMessage({id: 'admin.ldap.firstnameAttrTitle', defaultMessage: 'First Name Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.firstnameAttrEx', defaultMessage: 'E.g.: "givenName"'}), + help_text: defineMessage({id: 'admin.ldap.firstnameAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the first name of users in Mattermost.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.firstnameAttrDescHover', defaultMessage: 'When set, users cannot edit their first name, since it is synchronized with the LDAP server. When left blank, users can set their first name in Account Menu > Account Settings > Profile.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.LastNameAttribute', + label: defineMessage({id: 'admin.ldap.lastnameAttrTitle', defaultMessage: 'Last Name Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.lastnameAttrEx', defaultMessage: 'E.g.: "sn"'}), + help_text: defineMessage({id: 'admin.ldap.lastnameAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the last name of users in Mattermost.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.lastnameAttrDescHover', defaultMessage: 'When set, users cannot edit their last name, since it is synchronized with the LDAP server. When left blank, users can set their last name in Account Menu > Account Settings > Profile.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.NicknameAttribute', + label: defineMessage({id: 'admin.ldap.nicknameAttrTitle', defaultMessage: 'Nickname Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.nicknameAttrEx', defaultMessage: 'E.g.: "nickname"'}), + help_text: defineMessage({id: 'admin.ldap.nicknameAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the nickname of users in Mattermost.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.nicknameAttrDescHover', defaultMessage: 'When set, users cannot edit their nickname, since it is synchronized with the LDAP server. When left blank, users can set their nickname in Account Menu > Account Settings > Profile.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.PositionAttribute', + label: defineMessage({id: 'admin.ldap.positionAttrTitle', defaultMessage: 'Position Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.positionAttrEx', defaultMessage: 'E.g.: "title"'}), + help_text: defineMessage({id: 'admin.ldap.positionAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the position field in Mattermost.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.positionAttrDescHover', defaultMessage: 'When set, users cannot edit their position, since it is synchronized with the LDAP server. When left blank, users can set their position in Account Menu > Account Settings > Profile.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'text', + key: 'LdapSettings.PictureAttribute', + label: defineMessage({id: 'admin.ldap.pictureAttrTitle', defaultMessage: 'Profile Picture Attribute:'}), + placeholder: defineMessage({id: 'admin.ldap.pictureAttrEx', defaultMessage: 'E.g.: "thumbnailPhoto" or "jpegPhoto"'}), + help_text: defineMessage({id: 'admin.ldap.pictureAttrDesc', defaultMessage: '(Optional) The attribute in the AD/LDAP server used to populate the profile picture in Mattermost.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'button', + action: ldapTestAttributes, + key: 'LdapSettings.TestAttributes', + label: defineMessage({id: 'admin.ldap.testAttributesTitle', defaultMessage: 'Test Attributes'}), + help_text_markdown: false, + error_message: defineMessage({id: 'admin.ldap.testAttributesFailure', defaultMessage: 'We failed to find some attributes: {error}'}), + success_message: defineMessage({id: 'admin.ldap.testAttributesSuccess', defaultMessage: 'Test Successful'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'custom', + key: 'LdapSettings.CustomProfileAttributes', + component: CustomProfileAttributes, + isHidden: it.not(it.all( + it.minLicenseTier(LicenseSkus.Enterprise), + it.configIsTrue('FeatureFlags', 'CustomProfileAttributes'), + )), + }, + ], + }, + { + key: 'admin.authentication.ldap.group_synchronization', + title: 'Group Synchronization', + settings: [ + { + type: 'text', + key: 'LdapSettings.GroupDisplayNameAttribute', + label: defineMessage({id: 'admin.ldap.groupDisplayNameAttributeTitle', defaultMessage: 'Group Display Name Attribute:'}), + help_text: defineMessage({id: 'admin.ldap.groupDisplayNameAttributeDesc', defaultMessage: 'The attribute in the AD/LDAP server used to populate the group display names.'}), + placeholder: defineMessage({id: 'admin.ldap.groupDisplayNameAttributeEx', defaultMessage: 'E.g.: "cn"'}), + isHidden: it.not(it.licensedForFeature('LDAPGroups')), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + }, + { + type: 'text', + key: 'LdapSettings.GroupIdAttribute', + label: defineMessage({id: 'admin.ldap.groupIdAttributeTitle', defaultMessage: 'Group ID Attribute:'}), + help_text: defineMessage({id: 'admin.ldap.groupIdAttributeDesc', defaultMessage: 'The attribute in the AD/LDAP server used as a unique identifier for Groups.'}), + help_text_more_info: defineMessage({id: 'admin.ldap.groupIdAttributeDescHover', defaultMessage: 'This should be a AD/LDAP attribute with a value that does not change such as entryUUID for LDAP or objectGUID for Active Directory.'}), + help_text_markdown: false, + placeholder: defineMessage({id: 'admin.ldap.groupIdAttributeEx', defaultMessage: 'E.g.: "objectGUID" or "entryUUID"'}), + isHidden: it.not(it.licensedForFeature('LDAPGroups')), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + }, + { + type: 'button', + action: ldapTestGroupAttributes, + key: 'LdapSettings.TestGroupAttributes', + label: defineMessage({id: 'admin.ldap.testGroupAttributesTitle', defaultMessage: 'Test Group Attributes'}), + help_text_markdown: false, + error_message: defineMessage({id: 'admin.ldap.testGroupAttributesFailure', defaultMessage: 'We failed to find some attributes: {error}'}), + success_message: defineMessage({id: 'admin.ldap.testGroupAttributesSuccess', defaultMessage: 'Test Successful'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + ], + }, + { + key: 'admin.authentication.ldap.synchronization_performance', + title: 'Synchronization Performance', + sectionTitle: 'Sync Performance', + settings: [ + { + type: 'number', + key: 'LdapSettings.SyncIntervalMinutes', + label: defineMessage({id: 'admin.ldap.syncIntervalTitle', defaultMessage: 'Synchronization Interval (minutes):'}), + help_text: defineMessage({id: 'admin.ldap.syncIntervalHelpText', defaultMessage: 'AD/LDAP Synchronization updates Mattermost user information to reflect updates on the AD/LDAP server. For example, when a user\'s name changes on the AD/LDAP server, the change updates in Mattermost when synchronization is performed. Accounts removed from or disabled in the AD/LDAP server have their Mattermost accounts set to "Inactive" and have their account sessions revoked. Mattermost performs synchronization on the interval entered. For example, if 60 is entered, Mattermost synchronizes every 60 minutes.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'number', + key: 'LdapSettings.MaxPageSize', + label: defineMessage({id: 'admin.ldap.maxPageSizeTitle', defaultMessage: 'Maximum Page Size:'}), + placeholder: defineMessage({id: 'admin.ldap.maxPageSizeEx', defaultMessage: 'E.g.: "2000"'}), + help_text: defineMessage({id: 'admin.ldap.maxPageSizeHelpText', defaultMessage: 'The maximum number of users the Mattermost server will request from the AD/LDAP server at one time. 0 is unlimited.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + { + type: 'number', + key: 'LdapSettings.QueryTimeout', + label: defineMessage({id: 'admin.ldap.queryTitle', defaultMessage: 'Query Timeout (seconds):'}), + placeholder: defineMessage({id: 'admin.ldap.queryEx', defaultMessage: 'E.g.: "60"'}), + help_text: defineMessage({id: 'admin.ldap.queryDesc', defaultMessage: 'The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.all( + it.stateIsFalse('LdapSettings.Enable'), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + ), + }, + ], + }, + { + key: 'admin.authentication.ldap.synchronization_history', + title: 'Synchronize users to the system', + subtitle: 'See the table below for the status of each synchronization', + sectionTitle: 'Sync History', + settings: [ + { + type: 'jobstable', + job_type: Constants.JobTypes.LDAP_SYNC, + label: defineMessage({id: 'admin.ldap.sync_button', defaultMessage: 'AD/LDAP Synchronize Now'}), + help_text: defineMessage({id: 'admin.ldap.syncNowHelpText', defaultMessage: 'Initiates an AD/LDAP synchronization immediately. See the table below for status of each synchronization. Please review "System Console > Logs" and documentation to troubleshoot errors.'}), + help_text_markdown: false, + help_text_values: { + link: (msg: string) => ( + + {msg} + + ), + }, + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.LDAP)), + it.stateIsFalse('LdapSettings.EnableSync'), + ), + render_job: (job: Job) => { + if (job.status === 'pending') { + return {'--'}; + } + + let ldapUsers = 0; + let deleteCount = 0; + let updateCount = 0; + let linkedLdapGroupsCount; // Deprecated. + let totalLdapGroupsCount = 0; + let groupDeleteCount = 0; + let groupMemberDeleteCount = 0; + let groupMemberAddCount = 0; + + if (job && job.data) { + if (job.data.ldap_users_count && job.data.ldap_users_count.length > 0) { + ldapUsers = job.data.ldap_users_count; + } + + if (job.data.delete_count && job.data.delete_count.length > 0) { + deleteCount = job.data.delete_count; + } + + if (job.data.update_count && job.data.update_count.length > 0) { + updateCount = job.data.update_count; + } + + // Deprecated groups count representing the number of linked LDAP groups. + if (job.data.ldap_groups_count) { + linkedLdapGroupsCount = job.data.ldap_groups_count; + } + + // Groups count representing the total number of LDAP groups available based on + // the configured based DN and groups filter. + if (job.data.total_ldap_groups_count) { + totalLdapGroupsCount = job.data.total_ldap_groups_count; + } + + if (job.data.group_delete_count) { + groupDeleteCount = job.data.group_delete_count; + } + + if (job.data.group_member_delete_count) { + groupMemberDeleteCount = job.data.group_member_delete_count; + } + + if (job.data.group_member_add_count) { + groupMemberAddCount = job.data.group_member_add_count; + } + } + + return ( + + +
    + {updateCount > 0 && +
  • + +
  • + } + {deleteCount > 0 && +
  • + +
  • + } + {groupDeleteCount > 0 && +
  • + +
  • + } + {groupMemberDeleteCount > 0 && +
  • + +
  • + } + {groupMemberAddCount > 0 && +
  • + +
  • + } +
+
+ ); + }, + }, + ], + }], +}; diff --git a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap index a6d1ab683a..fa4489dc5a 100644 --- a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap @@ -129,231 +129,6 @@ exports[`components/AdminSidebar Plugins should match snapshot 1`] = `
    - - } - key="user_management" - parentLink="/admin_console" - sectionClass="" - title={ - - } - > - - } - title={ - - } - /> - - } - title={ - - } - /> - - - } - key="site" - parentLink="/admin_console" - sectionClass="" - title={ - - } - > - - } - title={ - - } - /> - - - } - key="authentication" - parentLink="/admin_console" - sectionClass="" - title={ - - } - > - - } - title={ - - } - /> - - } - title={ - - } - /> - - } - title={ - - } - /> - - } - title={ - - } - /> - - - } - key="compliance" - parentLink="/admin_console" - sectionClass="" - title={ - - } - > - - } - title={ - - } - /> - - } - title={ - - } - /> - - } - title={ - - } - /> -
diff --git a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts index 3294a33311..8144a5f3b1 100644 --- a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts +++ b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts @@ -23,7 +23,7 @@ import type {AdminConsolePluginComponent, AdminConsolePluginCustomSection} from import CustomPluginSettings from './custom_plugin_settings'; import getEnablePluginSetting from './enable_plugin_setting'; -import {it} from '../admin_definition'; +import {it} from '../admin_definition_helpers'; import {escapePathPart} from '../schema_admin_settings'; import type {AdminDefinitionSetting, AdminDefinitionSubSectionSchema, AdminDefinitionConfigSchemaSection} from '../types'; diff --git a/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx b/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx index 85ff293203..d2ad894039 100644 --- a/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx +++ b/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx @@ -112,7 +112,7 @@ const CustomProfileAttributes: React.FC = (props: Props): JSX.Element | n props.registerSaveAction(handleSave); return () => props.unRegisterSaveAction(handleSave); - }, [props.registerSaveAction, props.unRegisterSaveAction, attributes, originalAttributes, attributeKey, props]); + }, [props.registerSaveAction, props.unRegisterSaveAction, attributes, originalAttributes, attributeKey]); if (attributes.length === 0) { return null; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/index.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/index.tsx new file mode 100644 index 0000000000..6fe481fe2a --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/index.tsx @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import LDAPWizard from './ldap_wizard'; + +export default LDAPWizard; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx new file mode 100644 index 0000000000..cbdc573aa0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_boolean_setting.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import BooleanSetting from 'components/admin_console/boolean_setting'; + +import {renderLDAPSettingHelpText} from './ldap_helpers'; +import type {GeneralSettingProps} from './ldap_wizard'; + +import {renderLabel} from '../schema_admin_settings'; + +type BoolSettingProps = { + value: boolean; + onChange(id: string, value: any): void; + disabled: boolean; + setByEnv: boolean; +} & GeneralSettingProps + +const LDAPBooleanSetting = (props: BoolSettingProps) => { + const intl = useIntl(); + + if (!props.schema || !props.setting.key || props.setting.type !== 'bool') { + return null; + } + + const label = renderLabel(props.setting, props.schema, intl); + const helpText = renderLDAPSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + + return ( + + ); +}; + +export default LDAPBooleanSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_button_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_button_setting.tsx new file mode 100644 index 0000000000..dec9d91a3a --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_button_setting.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl, defineMessages} from 'react-intl'; + +import type {LdapDiagnosticResult, TestLdapFiltersResponse} from '@mattermost/types/admin'; +import type {LdapSettings} from '@mattermost/types/config'; + +import type {GeneralSettingProps, LDAPDefinitionSettingButton} from './ldap_wizard'; + +import RequestButton from '../request_button/request_button'; +import {descriptorOrStringToString, renderLabel, renderSettingHelpText} from '../schema_admin_settings'; + +type Props = { + setting: LDAPDefinitionSettingButton; + saveNeeded: boolean; + onChange(id: string, value: any): void; + disabled: boolean; + ldapSettingsState: LdapSettings; + onFilterTestResults?: (results: TestLdapFiltersResponse) => void; +} & GeneralSettingProps + +const LDAPButtonSetting = (props: Props) => { + const intl = useIntl(); + + if (!props.schema || props.setting.type !== 'button') { + return null; + } + + const handleRequestAction = (success: () => void, error: (error: { message: string }) => void) => { + if (!props.setting.skipSaveNeeded && props.saveNeeded !== false) { + error({ + message: intl.formatMessage({id: 'admin_settings.save_unsaved_changes', defaultMessage: 'Please save unsaved changes first'}), + }); + return; + } + const successCallback = (data?: LdapDiagnosticResult[]) => { + // If this is the filter test button or attribute test button and we have results, pass them to the handler + const isAttributeTest = props.setting.key === 'LdapSettings.TestAttributes'; + const isFiltersTest = props.setting.key === 'LdapSettings.TestFilters'; + const isGroupAttributeTest = props.setting.key === 'LdapSettings.TestGroupAttributes'; + + if ((isFiltersTest || isAttributeTest || isGroupAttributeTest) && props.onFilterTestResults && data) { + props.onFilterTestResults(data); + + const allTestsPassed = Array.isArray(data) && data.every((result) => result.error === ''); + if (allTestsPassed) { + success?.(); + } else { + const failedCount = data.filter((result) => result.error !== '').length; + const totalCount = data.length; + + let messageKey; + if (isGroupAttributeTest) { + messageKey = ldapButtonMessages.testGroupAttributesPartialFailure; + } else if (isAttributeTest) { + messageKey = ldapButtonMessages.testAttributesPartialFailure; + } else { + messageKey = ldapButtonMessages.testFiltersPartialFailure; + } + + error({ + message: intl.formatMessage(messageKey, {failedCount, totalCount}), + }); + } + } else { + // For non-test buttons, show success normally + success?.(); + } + }; + + props.setting.action(successCallback, error, props.ldapSettingsState); + }; + + const helpText = renderSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + const label = renderLabel(props.setting, props.schema, intl); + + return ( + {label}} + showSuccessMessage={Boolean(props.setting.success_message)} + disabled={props.disabled} + errorMessage={props.setting.error_message} + successMessage={props.setting.success_message} + flushLeft={true} + buttonType={'primary'} + /> + ); +}; + +const ldapButtonMessages = defineMessages({ + testFiltersPartialFailure: { + id: 'admin.ldap.testFiltersPartialFailure', + defaultMessage: '{failedCount, number} of {totalCount, number} filter test{totalCount, plural, one {} other {s}} failed. Check the highlighted fields for details.', + }, + testAttributesPartialFailure: { + id: 'admin.ldap.testAttributesPartialFailure', + defaultMessage: '{failedCount, number} of {totalCount, number} attribute test{totalCount, plural, one {} other {s}} failed. Check the highlighted fields for details.', + }, + testGroupAttributesPartialFailure: { + id: 'admin.ldap.testGroupAttributesPartialFailure', + defaultMessage: '{failedCount, number} of {totalCount, number} group attribute test{totalCount, plural, one {} other {s}} failed. Check the highlighted fields for details.', + }, +}); + +export default LDAPButtonSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_custom_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_custom_setting.tsx new file mode 100644 index 0000000000..780af92abb --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_custom_setting.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; + +import type {GeneralSettingProps} from './ldap_wizard'; + +import {renderLabel, renderSettingHelpText} from '../schema_admin_settings'; +import Setting from '../setting'; + +type Props = { + config: Partial; + license: ClientLicense; + value?: any; + registerSaveAction: (saveAction: () => Promise<{error?: {message?: string}}>) => void; + unRegisterSaveAction: (saveAction: () => Promise<{error?: {message?: string}}>) => void; + setSaveNeeded: () => void; + cancelSubmit: () => void; + showConfirmId: string; + onChange: (id: string, value: any, confirm: boolean, doSubmit: boolean, warning: boolean) => void; + disabled: boolean; + setByEnv: boolean; +} & GeneralSettingProps + +const LDAPCustomSetting = (props: Props) => { + const intl = useIntl(); + + if (!props.schema || props.setting.type !== 'custom') { + return null; + } + + const label = renderLabel(props.setting, props.schema, intl); + const helpText = renderSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + + const CustomComponent = props.setting.component; + + const componentInstance = ( + ); + + // Show the plugin custom setting title + // consistently as other settings with the Setting component + if (props.setting.showTitle) { + return ( + + {componentInstance} + + ); + } + + return componentInstance; +}; + +export default LDAPCustomSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_dropdown_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_dropdown_setting.tsx new file mode 100644 index 0000000000..d55d756003 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_dropdown_setting.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; + +import DropdownSetting from 'components/admin_console/dropdown_setting'; + +import type {GeneralSettingProps} from './ldap_wizard'; + +import {descriptorOrStringToString, renderDropdownOptionHelpText, renderLabel, renderSettingHelpText} from '../schema_admin_settings'; +import type {AdminDefinitionSettingDropdownOption} from '../types'; + +type Props = { + config: Partial; + state: Record; + license: ClientLicense; + enterpriseReady: boolean; + onChange(id: string, value: any): void; + disabled: boolean; + setByEnv: boolean; +} & GeneralSettingProps + +const LDAPDropdownSetting = (props: Props) => { + const intl = useIntl(); + + if (!props.schema || !props.setting.key || props.setting.type !== 'dropdown') { + return null; + } + + const options: AdminDefinitionSettingDropdownOption[] = []; + props.setting.options.forEach((option) => { + if (!option.isHidden || (typeof option.isHidden === 'function' && + !option.isHidden(props.config, props.state, props.license, props.enterpriseReady))) { + options.push(option); + } + }); + + const values = options.map((o) => ({value: o.value, text: descriptorOrStringToString(o.display_name, intl)!})); + const selectedValue = (props.state[props.setting.key] as string) ?? values[0].value; + + let selectedOptionForHelpText = null; + for (const option of options) { + if (option.help_text && option.value === selectedValue) { + selectedOptionForHelpText = option; + break; + } + } + + // used to hide help in case of cloud-starter and open-id selection to show upgrade notice. + let hideHelp = false; + if (props.setting.isHelpHidden) { + if (typeof (props.setting.isHelpHidden) === 'function') { + hideHelp = props.setting.isHelpHidden(props.config, props.state, props.license, props.enterpriseReady); + } else { + hideHelp = props.setting.isHelpHidden; + } + } + + const label = renderLabel(props.setting, props.schema, intl); + + let helpText: string | JSX.Element = ''; + if (!hideHelp) { + helpText = selectedOptionForHelpText ? renderDropdownOptionHelpText(selectedOptionForHelpText) : renderSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + } + return ( + + ); +}; + +export default LDAPDropdownSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_expandable_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_expandable_setting.tsx new file mode 100644 index 0000000000..8984d53435 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_expandable_setting.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {SettingsTypes} from 'utils/constants'; + +import type {LDAPDefinitionSetting, GeneralSettingProps} from './ldap_wizard'; + +import {renderLabel} from '../schema_admin_settings'; + +type ExpandableSettingProps = { + buildSettingFunction: (setting: LDAPDefinitionSetting) => React.ReactNode; +} & GeneralSettingProps + +const LDAPExpandableSetting = (props: ExpandableSettingProps) => { + const intl = useIntl(); + const [expanded, setExpanded] = useState(false); + + if (!props.schema || !props.setting.key || props.setting.type !== SettingsTypes.TYPE_EXPANDABLE_SETTING) { + return (<>); + } + + const toggleExpanded = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setExpanded(!expanded); + }; + + // Get the settings array from the expandable section setting + const settings = props.setting.settings || []; + const label = renderLabel(props.setting, props.schema, intl); + const contentId = `ldap-expandable-content-${props.setting.key}`; + + return ( +
+
+ + +
+
+ {settings.map((setting: LDAPDefinitionSetting, index: number) => ( +
+ {props.buildSettingFunction(setting)} +
+ ))} +
+
+ ); +}; + +export default LDAPExpandableSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_file_upload_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_file_upload_setting.tsx new file mode 100644 index 0000000000..d09e9ae655 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_file_upload_setting.tsx @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import FileUploadSetting from 'components/admin_console/file_upload_setting'; +import RemoveFileSetting from 'components/admin_console/remove_file_setting'; + +import {renderLDAPSettingHelpText} from './ldap_helpers'; +import type {GeneralSettingProps} from './ldap_wizard'; + +import {descriptorOrStringToString, renderLabel} from '../schema_admin_settings'; +import type {AdminDefinitionSettingFileUpload} from '../types'; + +type Props = { + setting: AdminDefinitionSettingFileUpload; + value?: string; + error?: string; + onChange(id: string, value: any): void; + fileUploadSetstate: (key: string, filename: string | null, error_message: string | null) => void; + disabled: boolean; + setByEnv: boolean; +} & GeneralSettingProps + +const LDAPFileUploadSetting = (props: Props) => { + const intl = useIntl(); + + if (!props.schema || props.setting.type !== 'fileupload' || !props.setting.key) { + return null; + } + + if (props.value) { + const removeFile = (id: string, callback: () => void) => { + const successCallback = () => { + props.onChange(id, ''); + props.fileUploadSetstate(props.setting.key!, null, null); + }; + const errorCallback = (error: any) => { + callback(); + props.fileUploadSetstate(props.setting.key!, null, error.message); + }; + props.setting.remove_action(successCallback, errorCallback); + }; + + const label = renderLabel(props.setting, props.schema, intl); + const helpText = renderLDAPSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + + return ( + + ); + } + const uploadFile = (id: string, file: File, callback: (error?: string) => void) => { + const successCallback = (filename: string) => { + props.onChange(id, filename); + props.fileUploadSetstate(props.setting.key!, filename, null); + callback?.(); + }; + const errorCallback = (error: any) => { + callback?.(error.message); + }; + props.setting.upload_action(file, successCallback, errorCallback); + }; + + const label = renderLabel(props.setting, props.schema, intl); + const helpText = renderLDAPSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + + return ( + + ); +}; + +export default LDAPFileUploadSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_helpers.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_helpers.tsx new file mode 100644 index 0000000000..83e6738b09 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_helpers.tsx @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, type MessageDescriptor} from 'react-intl'; + +import WithTooltip from 'components/with_tooltip'; + +import type {GeneralSettingProps} from './ldap_wizard'; + +import SchemaText from '../schema_text'; + +/** + * LDAP-specific component for rendering help text with hover tooltip + */ +export const LDAPHelpTextWithHover: React.FC<{ + baseText: string | JSX.Element | MessageDescriptor; + baseIsMarkdown?: boolean; + baseTextValues?: {[key: string]: any}; + hoverText: string | JSX.Element | MessageDescriptor; +}> = ({baseText, baseIsMarkdown, baseTextValues, hoverText}) => { + return ( + <> + + {' '} + + )} + > + + + + ); +}; + +/** + * LDAP-specific help text renderer that supports hover text + */ +export const renderLDAPSettingHelpText = ( + setting: GeneralSettingProps['setting'], + schema: GeneralSettingProps['schema'], + isDisabled: boolean, +) => { + if (!schema || setting.type === 'banner' || !setting.help_text) { + return {''}; + } + + let helpText; + let isMarkdown; + let helpTextValues; + if ('disabled_help_text' in setting && setting.disabled_help_text && isDisabled) { + helpText = setting.disabled_help_text; + isMarkdown = setting.disabled_help_text_markdown; + helpTextValues = setting.disabled_help_text_values; + } else { + helpText = setting.help_text; + isMarkdown = setting.help_text_markdown; + helpTextValues = setting.help_text_values; + } + + // Check if hover text is available (LDAP-specific extension) + if (setting.help_text_more_info) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_jobs_table_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_jobs_table_setting.tsx new file mode 100644 index 0000000000..ac637a4f67 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_jobs_table_setting.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import JobsTable from 'components/admin_console/jobs'; + +import type {GeneralSettingProps} from './ldap_wizard'; + +import {descriptorOrStringToString, renderSettingHelpText} from '../schema_admin_settings'; + +type Props = { + disabled: boolean; +} & GeneralSettingProps + +const LDAPJobsTableSetting = (props: Props) => { + const intl = useIntl(); + + if (!props.schema || props.setting.type !== 'jobstable') { + return null; + } + + const helpText = renderSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + + return ( + + ); +}; + +export default LDAPJobsTableSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_text_setting.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_text_setting.tsx new file mode 100644 index 0000000000..e79541d996 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_text_setting.tsx @@ -0,0 +1,249 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {useIntl, defineMessages} from 'react-intl'; + +import type {LdapDiagnosticResult} from '@mattermost/types/admin'; +import type {AdminConfig} from '@mattermost/types/config'; + +import TextSetting from 'components/admin_console/text_setting'; +import FormError, {TYPE_BACKSTAGE} from 'components/form_error'; +import WithTooltip from 'components/with_tooltip'; + +import Constants from 'utils/constants'; + +import {renderLDAPSettingHelpText} from './ldap_helpers'; +import type {GeneralSettingProps} from './ldap_wizard'; + +import {renderLabel} from '../schema_admin_settings'; + +type TextSettingProps = { + config: Partial; + state: Record; + placeholder?: string | MessageDescriptor; + onChange(id: string, value: any): void; + disabled: boolean; + setByEnv: boolean; + filterResult: LdapDiagnosticResult | null; +} & GeneralSettingProps + +const LDAPTextSetting = (props: TextSettingProps) => { + const intl = useIntl(); + + if (!props.schema || !props.setting.key || (props.setting.type !== 'text' && props.setting.type !== 'longtext' && props.setting.type !== 'number')) { + return null; + } + + let inputType: 'text' | 'number' | 'textarea' = 'text'; + if (props.setting.type === Constants.SettingsTypes.TYPE_NUMBER) { + inputType = 'number'; + } else if (props.setting.type === Constants.SettingsTypes.TYPE_LONG_TEXT) { + inputType = 'textarea'; + } + + let value: string; + if (props.setting.dynamic_value) { + const baseValue = props.state[props.setting.key] ?? (props.setting.default || ''); + const dynamicValue = props.setting.dynamic_value(baseValue, props.config, props.state); + value = sanitizeValue(dynamicValue); + } else if (props.setting.multiple) { + const arrayValue = props.state[props.setting.key] ? (props.state[props.setting.key] as string[]).join(',') : ''; + value = sanitizeValue(arrayValue); + } else { + const rawValue = (props.state[props.setting.key] as string) ?? (props.setting.default as string || ''); + value = sanitizeValue(rawValue); + } + + let footer = null; + if (props.setting.validate) { + const err = props.setting.validate(value).error(intl); + footer = err ? ( + + ) : footer; + } + + const label = renderLabel(props.setting, props.schema, intl); + const helpText = renderLDAPSettingHelpText(props.setting, props.schema, Boolean(props.disabled)); + + // Show icon if there was a test_value, even if there's no user input (to show effect of default settings) + // loose equality operator is intentional + const showFilterIcon = props.filterResult != null && + (props.filterResult.test_value !== '' || props.filterResult?.error !== ''); + + // Determine icon type and content - three states + const isFilter = isFilterTest(props.filterResult); + const isGroupAttribute = isGroupAttributeTest(props.filterResult); + const countReturned = (isFilter ? props.filterResult?.total_count : props.filterResult?.entries_with_value) || 0; + const isSuccess = props.filterResult?.error === '' && countReturned > 0; + const isWarning = props.filterResult?.error === '' && countReturned === 0; + + const getIconClass = () => { + if (isSuccess) { + return 'icon icon-check-circle'; + } + return 'icon icon-alert-outline'; // Used for both warning and failure + }; + + const getIconCssClass = () => { + if (isSuccess) { + return 'success'; + } + if (isWarning) { + return 'warning'; + } + return 'error'; + }; + + const iconClass = getIconClass(); + const iconCssClass = getIconCssClass(); + + const getTooltipContent = () => { + if (!props.filterResult) { + return ''; + } + + const totalCount = props.filterResult.total_count || 0; + const showDefaultDetails = showFilterIcon && + (value === '' || props.filterResult.test_name === 'UserFilter' || props.filterResult.test_name === 'GroupFilter'); + const testValue = showDefaultDetails ? props.filterResult.test_value : ''; + const showTestValue = showDefaultDetails; + + if (isSuccess) { + // If the filter has a testValue, but no userInput, the defaultValue + // was used. We need to tell the user what that value was. + let messageKey; + if (isFilter) { + messageKey = ldapTestMessages.filterTestSuccess; + } else if (isGroupAttribute) { + messageKey = ldapTestMessages.groupAttributeTestSuccess; + } else { + messageKey = ldapTestMessages.attributeTestSuccess; + } + return intl.formatMessage(messageKey, {countReturned, totalCount, testValue, showTestValue}); + } + + if (isWarning) { + let messageKey; + if (isFilter) { + messageKey = ldapTestMessages.filterTestWarning; + } else if (isGroupAttribute) { + messageKey = ldapTestMessages.groupAttributeTestWarning; + } else { + messageKey = ldapTestMessages.attributeTestWarning; + } + return intl.formatMessage(messageKey, {totalCount, testValue, showTestValue}); + } + + // For failed tests, use translated message with error included + let messageKey; + if (isFilter) { + messageKey = ldapTestMessages.filterTestFailed; + } else if (isGroupAttribute) { + messageKey = ldapTestMessages.groupAttributeTestFailed; + } else { + messageKey = ldapTestMessages.attributeTestFailed; + } + + const error = props.filterResult.error || ''; + const showError = Boolean(props.filterResult.error); + return intl.formatMessage(messageKey, {testValue, showTestValue, error, showError}); + }; + + return ( +
+ + {showFilterIcon && ( + + + + )} +
+ ); +}; + +function sanitizeValue(value: any): string { + if (value === null || value === undefined || Number.isNaN(value)) { + return ''; + } + return String(value); +} + +// Helper functions to determine test type from test result +function isFilterTest(testResult: LdapDiagnosticResult | null) { + if (!testResult) { + return false; + } + const filterTestNames = new Set(['BaseDN', 'UserFilter', 'GroupFilter', 'GuestFilter', 'AdminFilter']); + return filterTestNames.has(testResult.test_name); +} + +function isGroupAttributeTest(testResult: LdapDiagnosticResult | null) { + if (!testResult) { + return false; + } + const groupAttributeTestNames = new Set(['GroupDisplayNameAttribute', 'GroupIdAttribute']); + return groupAttributeTestNames.has(testResult.test_name); +} + +const ldapTestMessages = defineMessages({ + filterTestSuccess: { + id: 'admin.ldap.filterTestSuccess', + defaultMessage: 'Filter test successful: {countReturned, number} result{countReturned, plural, one {} other {s}} found{showTestValue, select, true {. Value used: {testValue}} other {}}', + }, + attributeTestSuccess: { + id: 'admin.ldap.attributeTestSuccess', + defaultMessage: 'Attribute test successful: {countReturned, number} result{countReturned, plural, one {} other {s}} found out of {totalCount} user{totalCount, plural, one {} other {s}} returned by the user filter', + }, + filterTestWarning: { + id: 'admin.ldap.filterTestWarning', + defaultMessage: 'Filter test successful but no results found. Your filter may be too restrictive.{showTestValue, select, true { Value used: {testValue}} other {}}', + }, + attributeTestWarning: { + id: 'admin.ldap.attributeTestWarning', + defaultMessage: 'The attribute was not found in any of the {totalCount} user{totalCount, plural, one {} other {s}} returned by the user filter', + }, + filterTestFailed: { + id: 'admin.ldap.filterTestFailed', + defaultMessage: 'Filter test failed{showTestValue, select, true {. Value used: {testValue}} other {}}{showError, select, true {: {error}} other {}}', + }, + attributeTestFailed: { + id: 'admin.ldap.attributeTestFailed', + defaultMessage: 'Attribute test failed{showError, select, true {: {error}} other {}}', + }, + groupAttributeTestSuccess: { + id: 'admin.ldap.groupAttributeTestSuccess', + defaultMessage: 'Group attribute test successful: {countReturned, number} result{countReturned, plural, one {} other {s}} found out of {totalCount} group{totalCount, plural, one {} other {s}} returned by the group filter', + }, + groupAttributeTestWarning: { + id: 'admin.ldap.groupAttributeTestWarning', + defaultMessage: 'The group attribute was not found in any of the {totalCount} group{totalCount, plural, one {} other {s}} returned by the group filter', + }, + groupAttributeTestFailed: { + id: 'admin.ldap.groupAttributeTestFailed', + defaultMessage: 'Group attribute test failed{showError, select, true {: {error}} other {}}', + }, +}); + +export default LDAPTextSetting; diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.scss b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.scss new file mode 100644 index 0000000000..bf4eb1506d --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.scss @@ -0,0 +1,173 @@ +.ldap-wizard-wrapper { + background-color: rgba(var(--center-channel-color-rgb), 0.04); + + .ldap-wizard-content-wrapper { + display: flex; + overflow: hidden; // Prevent double scrollbars on the body if content overflows + flex-grow: 1; + + .config-section { + border-color: rgba(var(--center-channel-color-rgb), 0.12); + border-radius: 8px; + margin-top: 32px; + + .section-body { + padding: 32px 48px 48px; + } + + &:first-child { + margin-top: 0; + } + } + } + + .admin-console__content { + padding-top: 20px; + } + + .ldap-wizard-sidebar { + display: flex; + flex-direction: column; + padding: 40px 52px; + gap: 8px; + + .ldap-wizard-sidebar-header { + display: flex; + padding: 0 0 8px 6px; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + } + + .ldap-wizard-sidebar-item { + padding: 8px 12px; + border: none; + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.75); + + font-weight: 600; + text-align: left; + transition: background-color 0.2s ease; + + &:hover:not(.ldap-wizard-sidebar-item--active) { + background-color: rgba(var(--center-channel-color-rgb), 0.04); + } + + &--active { + background-color: rgba(var(--button-bg-rgb), 0.04); + color: var(--button-bg); + } + } + } + + .section-header { + display: flex; + flex-direction: column; + padding: 48px 48px 0; + border-bottom: 0; + gap: 10px; + + .section-title { + font-family: Metropolis; + font-size: 22px; + line-height: 28px; + } + + .section-subtitle { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-family: Metropolis; + font-size: 16px; + line-height: 24px; + } + } + + .ldap-expandable-section { + margin: 20px 0; + + .ldap-expandable-section-header { + display: flex; + align-items: center; + gap: 8px; + } + + .ldap-expandable-section-toggle { + padding: 4px 0; + border: none; + background: none; + color: var(--button-bg); + font-weight: 600; + + &:hover { + text-decoration: underline; + } + + } + + .ldap-expandable-arrow { + color: var(--button-bg); + transition: transform 0.2s ease; + + &.open { + transform: rotate(90deg); + } + } + + .ldap-expandable-section-content { + overflow: hidden; + max-height: 0; + padding-left: 0; + margin-top: 0; + opacity: 0; + transition: max-height 0.3s ease-out, opacity 0.2s ease-out, margin-top 0.3s ease-out; + + &.expanded { + max-height: 800px; // Large enough to contain all settings + margin-top: 20px; + opacity: 1; + } + } + } + + .ldap-help-text-more-info { + padding: 0; + border: none; + background: none; + color: var(--button-bg); + cursor: pointer; + text-decoration: underline; + + } +} + +.tooltipContainer.ldap-help-text-hover-tooltip { + max-width: 400px; // Wider than the default 220px to accommodate detailed help text + text-align: left; // Left-align for better readability of longer text + white-space: pre-line; // Preserve line breaks in help text +} + + +.ldap-text-setting { + position: relative; + + .filter-icon { + position: absolute; + top: 10px; + right: 36px; + cursor: pointer; + font-size: 16px; + pointer-events: all; + + &success { + color: rgba(var(--semantic-color-success), 1); + } + + &warning { + color: rgba(var(--semantic-color-warning), 1); + } + + &error { + color: rgba(var(--semantic-color-danger), 1); + } + } +} diff --git a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.tsx b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.tsx new file mode 100644 index 0000000000..9ee55dc526 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.tsx @@ -0,0 +1,766 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState, useMemo} from 'react'; +import type {MessageDescriptor, WrappedComponentProps} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; + +import type {TestLdapFiltersResponse} from '@mattermost/types/admin'; +import type {CloudState} from '@mattermost/types/cloud'; +import type {AdminConfig, ClientLicense, EnvironmentConfig} from '@mattermost/types/config'; +import type {Role} from '@mattermost/types/roles'; +import type {DeepPartial} from '@mattermost/types/utilities'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import SettingsGroup from 'components/admin_console/settings_group'; +import {useSectionNavigation} from 'components/common/hooks/useSectionNavigation'; +import FormError from 'components/form_error'; +import SaveButton from 'components/save_button'; +import AdminHeader from 'components/widgets/admin_console/admin_header'; +import WithTooltip from 'components/with_tooltip'; + +import Constants from 'utils/constants'; + +import LDAPBooleanSetting from './ldap_boolean_setting'; +import LDAPButtonSetting from './ldap_button_setting'; +import LDAPCustomSetting from './ldap_custom_setting'; +import LDAPDropdownSetting from './ldap_dropdown_setting'; +import LDAPExpandableSetting from './ldap_expandable_setting'; +import LDAPFileUploadSetting from './ldap_file_upload_setting'; +import LDAPJobsTableSetting from './ldap_jobs_table_setting'; +import LDAPTextSetting from './ldap_text_setting'; + +import {ldapWizardAdminDefinition} from '../admin_definition_ldap_wizard'; +import {getConfigFromState, isSetByEnv, SchemaAdminSettings} from '../schema_admin_settings'; +import SchemaText from '../schema_text'; +import type {AdminDefinitionConfigSchemaSection, AdminDefinitionSetting, AdminDefinitionSettingButton, AdminDefinitionSettingFileUpload, AdminDefinitionSubSectionSchema, ConsoleAccess} from '../types'; +import './ldap_wizard.scss'; + +const SECTION_OBSERVER_OPTIONS: IntersectionObserverInit = { + root: null, // Use viewport as root + rootMargin: '-40% 0px -40% 0px', // Active when in the middle 20% of the viewport + threshold: 0.01, // At least 1% of element in this zone +}; + +export type LDAPDefinitionSettingButton = AdminDefinitionSettingButton & { + action: (success: () => void, error: (error: { message: string }) => void, settings?: Record) => void; +} + +export type LDAPDefinitionSetting = AdminDefinitionSetting & { + help_text_more_info?: string | JSX.Element | MessageDescriptor; +} + +export type LDAPAdminDefinitionConfigSchemaSettings = AdminDefinitionSubSectionSchema & { + sections?: LDAPAdminDefinitionConfigSchemaSection[]; +} + +export type LDAPAdminDefinitionConfigSchemaSection = Omit & { + sectionTitle?: string; + settings: LDAPDefinitionSetting[]; +} + +export type GeneralSettingProps = { + setting: LDAPDefinitionSetting; + schema: AdminDefinitionSubSectionSchema | null; +} + +type Props = { + config: Partial; + environmentConfig: Partial; + setNavigationBlocked: (blocked: boolean) => void; + roles: Record; + license: ClientLicense; + editRole: (role: Role) => void; + patchConfig: (config: DeepPartial) => Promise; + isDisabled: boolean; + consoleAccess: ConsoleAccess; + cloud: CloudState; + isCurrentUserSystemAdmin: boolean; + enterpriseReady: boolean; +} & WrappedComponentProps + +type State = { + [x: string]: unknown; + saveNeeded: false | 'both' | 'permissions' | 'config'; + saving: boolean; + serverError: string | { message: string; id?: string } | null; + confirmNeededId: string; + showConfirmId: string; + clientWarning: string | boolean; + prevSchemaId?: string; +} + +const LDAPWizard = (props: Props) => { + const schema = ldapWizardAdminDefinition; + + const [state, setState] = useState({ + saveNeeded: false, + saving: false, + serverError: null, + confirmNeededId: '', + showConfirmId: '', + clientWarning: '', + }); + + React.useEffect(() => { + if (props.config && schema) { + const initialStateFromConfig = SchemaAdminSettings.getStateFromConfig(props.config, schema, props.roles); + setState((prevState) => ({ + ...prevState, + ...initialStateFromConfig, + prevSchemaId: schema.id, + })); + } + }, [props.config, props.roles, schema]); + + const [saveActions, setSaveActions] = useState Promise<{ error?: { message?: string } }>>>([]); + + // Combined test results - both filter and attribute test results in one array + const [testResults, setTestResults] = useState(null); + + const getTestResult = useCallback((settingKey: string) => { + const testName = settingKeyToTestNameMap[settingKey]; + if (!testName || !testResults) { + return null; + } + + return testResults.find((result) => result.test_name === testName) || null; + }, [testResults]); + + const handleTestResults = useCallback((results: TestLdapFiltersResponse, testType: 'filter' | 'attribute' | 'groupAttribute') => { + const filteredResults = results.filter((result) => result.test_value !== '' || result?.error !== ''); + + setTestResults((prevResults) => { + // Object lookup for test type functions - cleaner than a switch statement + const testTypeFunctions = { + filter: isFilterTestName, + attribute: isAttributeTestName, + groupAttribute: isGroupAttributeTestName, + } as const; + + const isCurrentTestType = testTypeFunctions[testType] || (() => false); + + // Keep existing results from other test types, replace results from current test type + const existingResultsFromOtherTypes = prevResults ? prevResults.filter((result) => !isCurrentTestType(result.test_name)) : []; + + // Combine with new results + return [...existingResultsFromOtherTypes, ...filteredResults]; + }); + }, []); + + const memoizedSections = useMemo(() => { + return (schema && 'sections' in schema && schema.sections) ? schema.sections : []; + }, [schema]); + const memoizedSectionKeys = useMemo(() => { + return memoizedSections.map((section) => section.key); + }, [memoizedSections]); + + const {activeSectionKey, sectionRefs} = useSectionNavigation(memoizedSectionKeys, SECTION_OBSERVER_OPTIONS); + + const buildTextSetting = (setting: AdminDefinitionSetting) => { + const testResult = getTestResult(setting.key || ''); + return ( + + ); + }; + + const buildBoolSetting = (setting: AdminDefinitionSetting) => { + return ( + + ); + }; + + const buildDropdownSetting = (setting: AdminDefinitionSetting) => { + return ( + + ); + }; + + const buildButtonSetting = (setting: AdminDefinitionSetting | LDAPDefinitionSettingButton) => { + let config = JSON.parse(JSON.stringify(props.config)); + config = getConfigFromState(config, state, schema, isDisabled); + var testResultsHandler; + if (setting.key === 'LdapSettings.TestFilters') { + testResultsHandler = (results: TestLdapFiltersResponse) => handleTestResults(results, 'filter'); + } else if (setting.key === 'LdapSettings.TestAttributes') { + testResultsHandler = (results: TestLdapFiltersResponse) => handleTestResults(results, 'attribute'); + } else if (setting.key === 'LdapSettings.TestGroupAttributes') { + testResultsHandler = (results: TestLdapFiltersResponse) => handleTestResults(results, 'groupAttribute'); + } + + return ( + + ); + }; + + const buildJobsTableSetting = (setting: AdminDefinitionSetting) => { + return ( + + ); + }; + + const fileUploadSetstate = (key: string, filename: string | null, errorMessage: string | null) => { + setState((prev) => ({ + ...prev, + [key]: filename, + [key + 'Error']: errorMessage, + })); + }; + + const buildFileUploadSetting = (setting: AdminDefinitionSetting) => { + return ( + + ); + }; + + const buildCustomSetting = (setting: AdminDefinitionSetting) => { + return ( + + ); + }; + + const buildExpandableSetting = (setting: AdminDefinitionSetting) => { + return ( + { + if (buildSettingFunctions[subSetting.type] && !isHidden(subSetting as AdminDefinitionSetting)) { + return buildSettingFunctions[subSetting.type](subSetting as AdminDefinitionSetting); + } + return null; + }} + /> + ); + }; + + // To satisfy type checking + const nullFunction = () => { + return null; + }; + + const buildSettingFunctions = { + [Constants.SettingsTypes.TYPE_TEXT]: buildTextSetting, + [Constants.SettingsTypes.TYPE_LONG_TEXT]: buildTextSetting, + [Constants.SettingsTypes.TYPE_NUMBER]: buildTextSetting, + [Constants.SettingsTypes.TYPE_BOOL]: buildBoolSetting, + [Constants.SettingsTypes.TYPE_DROPDOWN]: buildDropdownSetting, + [Constants.SettingsTypes.TYPE_BUTTON]: buildButtonSetting, + [Constants.SettingsTypes.TYPE_JOBSTABLE]: buildJobsTableSetting, + [Constants.SettingsTypes.TYPE_FILE_UPLOAD]: buildFileUploadSetting, + [Constants.SettingsTypes.TYPE_CUSTOM]: buildCustomSetting, + [Constants.SettingsTypes.TYPE_EXPANDABLE_SETTING]: buildExpandableSetting, + [Constants.SettingsTypes.TYPE_COLOR]: nullFunction, + [Constants.SettingsTypes.TYPE_PERMISSION]: nullFunction, + [Constants.SettingsTypes.TYPE_RADIO]: nullFunction, + [Constants.SettingsTypes.TYPE_BANNER]: nullFunction, + [Constants.SettingsTypes.TYPE_GENERATED]: nullFunction, + [Constants.SettingsTypes.TYPE_USERNAME]: nullFunction, + [Constants.SettingsTypes.TYPE_LANGUAGE]: nullFunction, + [Constants.SettingsTypes.TYPE_ROLES]: nullFunction, + }; + + const isDisabled = (setting: AdminDefinitionSetting) => { + if (typeof setting.isDisabled === 'function') { + return setting.isDisabled(props.config, state, props.license, props.enterpriseReady, props.consoleAccess, props.cloud, props.isCurrentUserSystemAdmin); + } + return Boolean(setting.isDisabled); + }; + + const isHidden = (setting: AdminDefinitionSetting) => { + if (typeof setting.isHidden === 'function') { + return setting.isHidden(props.config, state, props.license); + } + return Boolean(setting.isHidden); + }; + + const renderTitle = () => { + if (!schema) { + return ''; + } + + let name: string | MessageDescriptor = schema.id; + if (('name' in schema)) { + name = schema.name; + } + + if (typeof name === 'string') { + return ( + + {name} + + ); + } + + return ( + + + + ); + }; + + const doSubmit = async ( + getStateFromConfig: ( + config: Partial, + schema: AdminDefinitionSubSectionSchema, + roles?: Record, + ) => Partial, + ) => { + if (!schema) { + return; + } + + // clone config so that we aren't modifying data in the stores + let config = JSON.parse(JSON.stringify(props.config)); + config = getConfigFromState(config, state, schema, isDisabled); + + const {error} = await props.patchConfig(config); + if (error) { + setState((prev) => ({ + ...prev, + serverError: error.message, + serverErrorId: error.id, + })); + } else { + setState((prevState) => ({ + ...prevState, + ...getStateFromConfig(config, schema), + })); + } + + const results = []; + for (const saveAction of saveActions) { + results.push(saveAction()); + } + + const hasSaveActionError = await Promise.all(results).then((values) => values.some(((value) => value.error && value.error.message))); + + const hasError = error || hasSaveActionError; + if (hasError) { + setState((prev) => ({ + ...prev, + saving: false, + })); + } else { + setState((prev) => ({ + ...prev, + saving: false, + saveNeeded: false, + confirmNeededId: '', + showConfirmId: '', + clientWarning: '', + serverError: null, + })); + props.setNavigationBlocked(false); + } + }; + + const handleChange = (id: string, value: unknown, confirm = false, shouldSubmit = false, warning = false) => { + let saveNeeded: State['saveNeeded'] = state.saveNeeded === 'permissions' ? 'both' : 'config'; + + // Exception: Since OpenId-Custom is treated as feature discovery for Cloud Starter licenses, save button is disabled. + const isCloudStarter = props.license.Cloud === 'true' && props.license.SkuShortName === 'starter'; + if (id === 'openidType' && value === 'openid' && isCloudStarter) { + saveNeeded = false; + } + + const clientWarning = warning === false ? state.clientWarning : warning; + + let confirmNeededId = confirm ? id : state.confirmNeededId; + if (id === state.confirmNeededId && !confirm) { + confirmNeededId = ''; + } + + setState((prev) => ({ + ...prev, + saveNeeded, + confirmNeededId, + clientWarning, + [id]: value, + })); + + // Clear test results when user starts typing in test fields + if (id in settingKeyToTestNameMap) { + const testName = settingKeyToTestNameMap[id]; + + setTestResults((prevResults) => { + if (!prevResults) { + return null; + } + return prevResults.filter((result) => result.test_name !== testName); + }); + } + + if (shouldSubmit) { + doSubmit(SchemaAdminSettings.getStateFromConfig); + } + + props.setNavigationBlocked(true); + }; + + const handleSubmit = async ( + e: React.MouseEvent | React.FormEvent, + ) => { + e.preventDefault(); + + if (state.confirmNeededId) { + setState((prev) => ({ + ...prev, + showConfirmId: prev.confirmNeededId, + })); + return; + } + + setState((prev) => ({ + ...prev, + saving: true, + serverError: null, + })); + + if (state.saveNeeded === 'both' || state.saveNeeded === 'config') { + doSubmit(SchemaAdminSettings.getStateFromConfig); + } else { + setState((prev) => ({ + ...prev, + saving: false, + saveNeeded: false, + serverError: null, + })); + props.setNavigationBlocked(false); + } + }; + + const unRegisterSaveAction = useCallback((saveAction: () => Promise<{ error?: { message?: string } }>) => { + setSaveActions((prev) => prev.filter((action) => action !== saveAction)); + }, []); + + const registerSaveAction = useCallback((saveAction: () => Promise<{ error?: { message?: string } }>) => { + setSaveActions((prev) => [...prev, saveAction]); + }, []); + + const setSaveNeeded = () => { + setState((prev) => ({ + ...prev, + saveNeeded: 'config', + })); + props.setNavigationBlocked(true); + }; + + const cancelSubmit = () => { + setState((prev) => ({ + ...prev, + showConfirmId: '', + })); + }; + + const canSave = () => { + if (!schema || !('settings' in schema) || !schema.settings) { + return true; + } + + for (const setting of schema.settings) { + // Some settings are actually not settings (banner) + // and don't have a key, skip those ones + if (!('key' in setting) || !setting.key) { + continue; + } + + // don't validate elements set by env. + if (isSetByEnv(setting.key, props.environmentConfig)) { + continue; + } + + if ('validate' in setting && setting.validate) { + if ('isHidden' in setting) { + let hidden = false; + if (typeof setting.isHidden === 'function') { + hidden = setting.isHidden?.(props.config, state, props.license, props.enterpriseReady, props.consoleAccess, props.cloud, props.isCurrentUserSystemAdmin); + } else { + hidden = Boolean(setting.isHidden); + } + + // MM-50952 + // If the setting is hidden, then it is not being set in state so there is + // nothing to validate, and validation would fail anyways and prevent saving + // In practice, this only happens in custom cloud setup environments like RFQA + // where it sets things in the config file directly instead of in the environment + // (like cloud Mattermost does) + if (hidden) { + continue; + } + } + const result = setting.validate(state[setting.key]); + if (!result.isValid()) { + return false; + } + } + } + + return true; + }; + + const hybridSchemaAndComponent = () => { + if (schema && 'component' in schema && schema.component) { + const CustomComponent = schema.component; + return ( + + ); + } + return null; + }; + + const renderSidebar = () => { + return ( +
+
+ + +
+ {memoizedSections.map((section) => ( + + ))} +
+ ); + }; + + const renderSettings = () => { + const renderedSections = memoizedSections.map((section) => { + const settingsList: React.ReactNode[] = []; + if (section.settings) { + section.settings.forEach((setting) => { + if (buildSettingFunctions[setting.type] && !isHidden(setting)) { + settingsList.push(buildSettingFunctions[setting.type](setting)); + } + }); + } + + if (section.component) { + const CustomComponent = section.component; + return ( + + ); + } + + let header; + if (section.header) { + header = ( +
+ +
+ ); + } + + let footer; + if (section.footer) { + footer = ( +
+ +
+ ); + } + + return ( +
{ + if (sectionRefs.current) { + sectionRefs.current[section.key] = el; + } + }} + > + +
+ {header} + {settingsList} + {footer} +
+
+
+ ); + }); + + return ( +
+ {renderedSections} +
+ ); + }; + + return ( +
+ {renderTitle()} +
+ {renderSidebar()} +
+
+
+ {renderSettings()} +
+ {hybridSchemaAndComponent()} +
+
+
+
+ + +
+ + +
+
+
+
+ ); +}; + +// Helper functions for filter, attribute, and group attribute test results +const settingKeyToTestNameMap: Record = { + 'LdapSettings.BaseDN': 'BaseDN', + 'LdapSettings.UserFilter': 'UserFilter', + 'LdapSettings.GroupFilter': 'GroupFilter', + 'LdapSettings.GuestFilter': 'GuestFilter', + 'LdapSettings.AdminFilter': 'AdminFilter', + 'LdapSettings.IdAttribute': 'IdAttribute', + 'LdapSettings.LoginIdAttribute': 'LoginIdAttribute', + 'LdapSettings.UsernameAttribute': 'UsernameAttribute', + 'LdapSettings.EmailAttribute': 'EmailAttribute', + 'LdapSettings.FirstNameAttribute': 'FirstNameAttribute', + 'LdapSettings.LastNameAttribute': 'LastNameAttribute', + 'LdapSettings.NicknameAttribute': 'NicknameAttribute', + 'LdapSettings.PositionAttribute': 'PositionAttribute', + 'LdapSettings.PictureAttribute': 'PictureAttribute', + 'LdapSettings.GroupDisplayNameAttribute': 'GroupDisplayNameAttribute', + 'LdapSettings.GroupIdAttribute': 'GroupIdAttribute', +}; + +// Helper functions to categorize test types +const filterTestNames = new Set(['BaseDN', 'UserFilter', 'GroupFilter', 'GuestFilter', 'AdminFilter']); +const attributeTestNames = new Set(['IdAttribute', 'LoginIdAttribute', 'UsernameAttribute', 'EmailAttribute', 'FirstNameAttribute', 'LastNameAttribute', 'NicknameAttribute', 'PositionAttribute', 'PictureAttribute']); +const groupAttributeTestNames = new Set(['GroupDisplayNameAttribute', 'GroupIdAttribute']); + +const isFilterTestName = (testName: string) => filterTestNames.has(testName); +const isAttributeTestName = (testName: string) => attributeTestNames.has(testName); +const isGroupAttributeTestName = (testName: string) => groupAttributeTestNames.has(testName); + +export default LDAPWizard; diff --git a/webapp/channels/src/components/admin_console/request_button/request_button.tsx b/webapp/channels/src/components/admin_console/request_button/request_button.tsx index 7f08212e88..d1ecc37efe 100644 --- a/webapp/channels/src/components/admin_console/request_button/request_button.tsx +++ b/webapp/channels/src/components/admin_console/request_button/request_button.tsx @@ -103,6 +103,18 @@ type Props = { * An element to display adjacent to the request button. */ alternativeActionElement?: React.ReactNode; + + /** + * True if the button should be displayed flush left without the col-sm-offset-4 class, + * otherwise false. + */ + flushLeft?: boolean; + + /** + * The button type/variant to apply. Determines the button's visual style. + * Defaults to 'tertiary'. + */ + buttonType?: 'primary' | 'secondary' | 'tertiary'; }; type State = { @@ -211,11 +223,14 @@ export default class RequestButton extends React.PureComponent { let widgetClassNames = 'col-sm-8'; let label = null; if (this.props.label) { + // When there's a label, widget takes remaining 8 columns regardless of flushLeft label = ( ); + } else if (this.props.flushLeft) { + widgetClassNames = 'col-sm-12'; } else { widgetClassNames = 'col-sm-offset-4 ' + widgetClassNames; } @@ -230,7 +245,7 @@ export default class RequestButton extends React.PureComponent {